From 1e20a1ccdddab771d8ed3ad2063510f0e624f27a Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Tue, 17 Feb 2026 13:52:59 +0100 Subject: [PATCH 1/7] feat: R2DBC DAO base structure --- build.gradle.kts | 4 +- exposed-dao-r2dbc-tests/build.gradle.kts | 80 +++++ .../r2dbc/tests/shared/R2dbcEntityTests.kt | 282 ++++++++++++++++++ exposed-dao-r2dbc/api/exposed-dao-r2dbc.api | 176 +++++++++++ exposed-dao-r2dbc/build.gradle.kts | 38 +++ .../jetbrains/exposed/r2dbc/dao/IntEntity.kt | 12 + .../r2dbc/dao/LinkedIdentityHashSet.kt | 75 +++++ .../jetbrains/exposed/r2dbc/dao/LongEntity.kt | 12 + .../exposed/r2dbc/dao/R2dbcDaoEntityID.kt | 10 + .../exposed/r2dbc/dao/R2dbcEntity.kt | 198 ++++++++++++ .../exposed/r2dbc/dao/R2dbcEntityCache.kt | 183 ++++++++++++ .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 129 ++++++++ .../dao/R2dbcEntityLifecycleInterceptor.kt | 112 +++++++ .../R2dbcEntityNotFoundException.kt | 7 + .../dao/relationships/R2dbcBackReference.kt | 50 ++++ .../r2dbc/dao/relationships/R2dbcReferrers.kt | 81 +++++ .../R2dbcRelationshipExtensions.kt | 78 +++++ .../dao/relationships/SuspendAccessor.kt | 161 ++++++++++ ...atements.GlobalSuspendStatementInterceptor | 1 + .../v1/r2dbc/sql/tests/shared/AliasesTests.kt | 48 ++- .../sql/tests/shared/entities/EntityTests.kt | 78 ----- .../r2dbc/mappers/ExposedColumnTypeMapper.kt | 8 +- .../v1/tests/shared/entities/EntityTests.kt | 45 ++- settings.gradle.kts | 2 + 24 files changed, 1774 insertions(+), 96 deletions(-) create mode 100644 exposed-dao-r2dbc-tests/build.gradle.kts create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt create mode 100644 exposed-dao-r2dbc/api/exposed-dao-r2dbc.api create mode 100644 exposed-dao-r2dbc/build.gradle.kts create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LinkedIdentityHashSet.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcDaoEntityID.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt create mode 100644 exposed-dao-r2dbc/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor delete mode 100644 exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/entities/EntityTests.kt diff --git a/build.gradle.kts b/build.gradle.kts index fba66a2cab..306724637b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,8 @@ allprojects { if (this.name != "exposed-tests" && this.name != "exposed-r2dbc-tests" && this.name != "exposed-jdbc-r2dbc-tests" && + this.name != "exposed-dao-r2dbc-tests" && + this.name != "exposed-dao-r2dbc" && this != rootProject ) { apply(plugin = "com.vanniktech.maven.publish") @@ -91,7 +93,7 @@ allprojects { } apiValidation { - ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests")) + ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests")) } subprojects { diff --git a/exposed-dao-r2dbc-tests/build.gradle.kts b/exposed-dao-r2dbc-tests/build.gradle.kts new file mode 100644 index 0000000000..669e17844f --- /dev/null +++ b/exposed-dao-r2dbc-tests/build.gradle.kts @@ -0,0 +1,80 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") apply true +} + +kotlin { + jvmToolchain(17) + + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn.add("kotlinx.coroutines.DelicateCoroutinesApi") + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.kotlinx.coroutines.reactive) + implementation(libs.kotlinx.coroutines.debug) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.r2dbc.spi) + + implementation(kotlin("test-junit5")) + implementation(libs.junit5) + testRuntimeOnly(libs.junit.platform.launcher) + + implementation(project(":exposed-core")) + implementation(project(":exposed-r2dbc")) + implementation(project(":exposed-dao-r2dbc")) + implementation(project(":exposed-r2dbc-tests")) + + implementation(libs.slf4j) + implementation(libs.log4j.slf4j.impl) + implementation(libs.log4j.api) + implementation(libs.log4j.core) + + testRuntimeOnly(libs.r2dbc.pool) + testImplementation(libs.r2dbc.h2) { + exclude(group = "com.h2database", module = "h2") + } + testRuntimeOnly(libs.r2dbc.mariadb) + testRuntimeOnly(libs.r2dbc.mysql) + testRuntimeOnly(libs.r2dbc.oracle) + testImplementation(libs.r2dbc.postgresql) + testRuntimeOnly(libs.r2dbc.sqlserver) + + testImplementation(libs.logcaptor) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +tasks.withType().configureEach { + targetCompatibility = "17" +} + +tasks.withType().configureEach { + if (JavaVersion.VERSION_11 > JavaVersion.current()) { + jvmArgs = listOf("-XX:MaxPermSize=256m") + } + testLogging { + events.addAll(listOf(TestLogEvent.PASSED, TestLogEvent.FAILED, TestLogEvent.SKIPPED)) + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } + + useJUnitPlatform() +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt new file mode 100644 index 0000000000..ad4f52f6b3 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt @@ -0,0 +1,282 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcIntEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcIntEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcLongEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcLongEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.backReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalBackReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferrersOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +object EntityTestsData { + + object YTable : IdTable("YTable") { + override val id: Column> = varchar("uuid", 36).entityId().clientDefault { + EntityID(UUID.randomUUID().toString(), YTable) + } + + val x = bool("x").default(true) + + override val primaryKey = PrimaryKey(id) + } + + object XTable : IntIdTable("XTable") { + val b1 = bool("b1").default(true) + val b2 = bool("b2").default(false) + val y1 = optReference("y1", YTable) + } + + class XEntity(id: EntityID) : R2dbcEntity(id) { + var b1 by XTable.b1 + var b2 by XTable.b2 + + companion object : R2dbcEntityClass(XTable) + } + + enum class XType { + A, B + } + + open class AEntity(id: EntityID) : R2dbcIntEntity(id) { + var b1 by XTable.b1 + + companion object : R2dbcIntEntityClass(XTable) { + fun create(b1: Boolean, type: XType): AEntity { + val init: AEntity.() -> Unit = { + this.b1 = b1 + } + val answer = when (type) { + XType.B -> BEntity.create { init() } + else -> new { init() } + } + return answer + } + } + } + + class BEntity(id: EntityID) : AEntity(id) { + var b2 by XTable.b2 + val y by YEntity optionalReferencedOnSuspend XTable.y1 + + companion object : R2dbcIntEntityClass(XTable) { + fun create(init: AEntity.() -> Unit): BEntity { + val answer = new { + init() + } + return answer + } + } + } + + class YEntity(id: EntityID) : R2dbcEntity(id) { + var x by YTable.x + val b by BEntity backReferencedOnSuspend XTable.y1 + val bOpt by BEntity optionalBackReferencedOnSuspend XTable.y1 + + companion object : R2dbcEntityClass(YTable) + } +} + +class R2dbcEntityTests : R2dbcDatabaseTestsBase() { + @Test + fun testDefaults01() { + withTables(EntityTestsData.YTable, EntityTestsData.XTable) { + val x = EntityTestsData.XEntity.new { } + assertEquals(x.b1, true, "b1 mismatched") + assertEquals(x.b2, false, "b2 mismatched") + } + } + + @Test + fun testDefaults02() { + withTables(EntityTestsData.YTable, EntityTestsData.XTable) { + val a: EntityTestsData.AEntity = EntityTestsData.AEntity.create(false, EntityTestsData.XType.A) + val b: EntityTestsData.BEntity = EntityTestsData.AEntity.create(false, EntityTestsData.XType.B) as EntityTestsData.BEntity + val y = EntityTestsData.YEntity.new { x = false } + + assertEquals(a.b1, false, "a.b1 mismatched") + assertEquals(b.b1, false, "b.b1 mismatched") + assertEquals(b.b2, false, "b.b2 mismatched") + + b.y set y + + assertFalse(b.y()!!.x) + assertNotNull(y.b()) + } + } + + @Test + fun testTextFieldOutsideTheTransaction() { + val objectsToVerify = arrayListOf>() + withTables(Humans) { testDb -> + val y1 = Human.new { + h = "foo" + } + + flushCache() + y1.refresh(flush = false) + + objectsToVerify.add(y1 to testDb) + } + objectsToVerify.forEach { (human, testDb) -> + assertEquals("foo", human.h, "Failed on ${testDb.name}") + } + } + + @Test + fun testNewWithIdAndRefresh() { + val objectsToVerify = arrayListOf>() + withTables(listOf(TestDB.SQLSERVER), Humans) { testDb -> + val x = Human.new(2) { + h = "foo" + } + x.refresh(flush = true) + objectsToVerify.add(x to testDb) + } + objectsToVerify.forEach { (human, testDb) -> + assertEquals("foo", human.h, "Failed on ${testDb.name}") + assertEquals(2, human.id.value, "Failed on ${testDb.name}") + } + } + + internal object OneAutoFieldTable : IntIdTable("single") + internal class SingleFieldEntity(id: EntityID) : R2dbcIntEntity(id) { + companion object : R2dbcIntEntityClass(OneAutoFieldTable) + } + + @Test + fun testOneFieldEntity() { + withTables(OneAutoFieldTable) { + val new = SingleFieldEntity.new { } + commit() + } + } + + @Test + fun testBackReference01() { + withTables(EntityTestsData.YTable, EntityTestsData.XTable) { + val y = EntityTestsData.YEntity.new { } + flushCache() + val b = EntityTestsData.BEntity.new { } + b.y set y + assertEquals(b, y.b()) + } + } + + @Test + fun testBackReference02() { + withTables(EntityTestsData.YTable, EntityTestsData.XTable) { + val b = EntityTestsData.BEntity.new { } + flushCache() + val y = EntityTestsData.YEntity.new { } + b.y set y + assertEquals(b, y.b()) + } + } + + object Boards : IntIdTable(name = "board") { + val name = varchar("name", 255).index(isUnique = true) + } + + object Posts : LongIdTable(name = "posts") { + val board = optReference("board", Boards.id) + val parent = optReference("parent", this) + val category = optReference("category", Categories.uniqueId).uniqueIndex() + val optCategory = optReference("optCategory", Categories.uniqueId) + } + + object Categories : IntIdTable() { + val uniqueId = uuid("uniqueId").autoGenerate().uniqueIndex() + val title = varchar("title", 50) + } + + class Board(id: EntityID) : R2dbcIntEntity(id) { + companion object : R2dbcIntEntityClass(Boards) + + var name by Boards.name + val posts by Post optionalReferrersOnSuspend Posts.board + } + + class Post(id: EntityID) : R2dbcLongEntity(id) { + companion object : R2dbcLongEntityClass(Posts) + + val board by Board optionalReferencedOnSuspend Posts.board + val parent by Post optionalReferencedOnSuspend Posts.parent + val category by Category optionalReferencedOnSuspend Posts.category + val optCategory by Category optionalReferencedOnSuspend Posts.optCategory + } + + class Category(id: EntityID) : R2dbcIntEntity(id) { + companion object : R2dbcIntEntityClass(Categories) + + val uniqueId by Categories.uniqueId + var title by Categories.title + val posts by Post optionalReferrersOnSuspend Posts.optCategory + + override fun equals(other: Any?) = (other as? Category)?.id?.equals(id) == true + override fun hashCode() = id.value.hashCode() + } + + @Test + fun tableSelfReferenceTest() { + assertEquals(listOf(Boards, Categories, Posts), SchemaUtils.sortTablesByReferences(listOf(Posts, Boards, Categories))) + assertEquals(listOf(Categories, Boards, Posts), SchemaUtils.sortTablesByReferences(listOf(Categories, Posts, Boards))) + assertEquals(listOf(Boards, Categories, Posts), SchemaUtils.sortTablesByReferences(listOf(Posts))) + } + + @Test + fun testInsertChildWithoutFlush() { + withTables(Boards, Posts, Categories) { + val parent = Post.new { this.category set Category.new { title = "title" } } + Post.new { this.parent set parent } // first flush before referencing + assertEquals(2L, Post.all().count()) + } + } + + object Humans : IntIdTable("human") { + val h = text("h", eagerLoading = true) + } + + object Users : IdTable("user") { + override val id: Column> = reference("id", Humans) + val name = text("name") + } + + open class Human(id: EntityID) : R2dbcIntEntity(id) { + companion object : R2dbcIntEntityClass(Humans) + + var h by Humans.h + } + + class User(id: EntityID) : R2dbcIntEntity(id) { + companion object : R2dbcIntEntityClass(Users) { + fun create(name: String): User { + val h = Human.new { h = name.take(2) } + return User.new(h.id.value) { + this.name = name + } + } + } + + val human by Human referencedOnSuspend Users.id + var name by Users.name + } +} diff --git a/exposed-dao-r2dbc/api/exposed-dao-r2dbc.api b/exposed-dao-r2dbc/api/exposed-dao-r2dbc.api new file mode 100644 index 0000000000..6019b6f889 --- /dev/null +++ b/exposed-dao-r2dbc/api/exposed-dao-r2dbc.api @@ -0,0 +1,176 @@ +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcDaoEntityID : org/jetbrains/exposed/v1/core/dao/id/EntityID { + public fun (Ljava/lang/Object;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)V +} + +public class org/jetbrains/exposed/r2dbc/dao/R2dbcEntity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V + public fun flush (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityBatchUpdate;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun flush$default (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityBatchUpdate;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun getDb ()Lorg/jetbrains/exposed/v1/r2dbc/R2dbcDatabase; + public final fun getId ()Lorg/jetbrains/exposed/v1/core/dao/id/EntityID; + public final fun getKlass ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass; + public final fun getReadValues ()Lorg/jetbrains/exposed/v1/core/ResultRow; + public final fun getValue (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Ljava/lang/Object; + public final fun getWriteValues ()Ljava/util/LinkedHashMap; + public final fun get_readValues ()Lorg/jetbrains/exposed/v1/core/ResultRow; + public final fun lookup (Lorg/jetbrains/exposed/v1/core/Column;)Ljava/lang/Object; + public fun refresh (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun refresh$default (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun setValue (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;Ljava/lang/Object;)V + public final fun set_readValues (Lorg/jetbrains/exposed/v1/core/ResultRow;)V +} + +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityBatchUpdate { + public fun ()V + public final fun set (Lorg/jetbrains/exposed/v1/core/Column;Ljava/lang/Object;)V +} + +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache { + public fun (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;)V + public final fun addNotInitializedEntityToQueue (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun find (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + public final fun findAll (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)Ljava/util/List; + public final fun finishEntityInitialization (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun flush (Ljava/lang/Iterable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun flush (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getData ()Ljava/util/concurrent/ConcurrentHashMap; + public final fun getMaxEntitiesToStore ()I + public final fun getOrPutReferrers (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getPendingInitializationLambdas ()Ljava/util/concurrent/ConcurrentHashMap; + public final fun isEntityInInitializationState (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)Z + public final fun remove (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun removeReferrer (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V + public final fun removeTablesReferrers (Ljava/util/Collection;)V + public final fun scheduleInsert (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun scheduleUpdate (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun setMaxEntitiesToStore (I)V + public final fun store (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun updateEntities (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCacheKt { + public static final fun getEntityCache (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache; +} + +public abstract class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun all ()Lorg/jetbrains/exposed/v1/r2dbc/Query; + protected fun createInstance (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + public final fun find (Lkotlin/jvm/functions/Function0;)Lorg/jetbrains/exposed/v1/r2dbc/Query; + public final fun find (Lorg/jetbrains/exposed/v1/core/Op;)Lorg/jetbrains/exposed/v1/r2dbc/Query; + public fun findById (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun get (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun get (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getDependsOnColumns ()Ljava/util/List; + public fun getDependsOnTables ()Lorg/jetbrains/exposed/v1/core/ColumnSet; + public final fun getTable ()Lorg/jetbrains/exposed/v1/core/dao/id/IdTable; + public fun new (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + public fun new (Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + public final fun removeFromCache (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public fun searchQuery (Lorg/jetbrains/exposed/v1/core/Op;)Lorg/jetbrains/exposed/v1/r2dbc/Query; + public final fun testCache (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + protected fun warmCache ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache; + public final fun wrap (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; + public final fun wrapRow (Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity; +} + +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor : org/jetbrains/exposed/v1/r2dbc/statements/GlobalSuspendStatementInterceptor { + public fun ()V + public fun afterCommit (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun afterExecution (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Ljava/util/List;Lorg/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcPreparedStatementApi;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun afterRollback (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun afterStatementPrepared (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lorg/jetbrains/exposed/v1/r2dbc/statements/api/R2dbcPreparedStatementApi;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun beforeCommit (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun beforeExecution (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lorg/jetbrains/exposed/v1/core/statements/StatementContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun beforeRollback (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun keepUserDataInTransactionStoreOnCommit (Ljava/util/Map;)Ljava/util/Map; +} + +public final class org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptorKt { + public static final fun flushCache (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract class org/jetbrains/exposed/r2dbc/dao/R2dbcIntEntity : org/jetbrains/exposed/r2dbc/dao/R2dbcEntity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/r2dbc/dao/R2dbcIntEntityClass : org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public abstract class org/jetbrains/exposed/r2dbc/dao/R2dbcLongEntity : org/jetbrains/exposed/r2dbc/dao/R2dbcEntity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/r2dbc/dao/R2dbcLongEntityClass : org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Ljava/lang/Class;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException : java/lang/Exception { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)V + public final fun getEntity ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass; + public final fun getId ()Lorg/jetbrains/exposed/v1/core/dao/id/EntityID; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/OptionalSuspendAccessor { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun getValue (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/OptionalSuspendAccessor; + public final fun invoke (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun set (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/OptionalSuspendReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)V + public final fun getFactory ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun provideDelegate (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/OptionalSuspendAccessor; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)V + public final fun getValue (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Lkotlin/jvm/functions/Function1; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcOptionalBackReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)V + public final fun getValue (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Lkotlin/jvm/functions/Function1; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Z)V + public final fun getCache ()Z + public final fun getFactory ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getValue (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Lkotlin/jvm/functions/Function1; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensionsKt { + public static final fun backReferencedOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference; + public static final fun optionalBackReferencedOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcOptionalBackReference; + public static final fun optionalReferencedOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/OptionalSuspendReference; + public static final fun optionalReferrersOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; + public static final fun optionalReferrersOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;Z)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; + public static synthetic fun optionalReferrersOnSuspend$default (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;ZILjava/lang/Object;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; + public static final fun referencedOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/SuspendReference; + public static final fun referrersOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; + public static final fun referrersOnSuspend (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;Z)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; + public static synthetic fun referrersOnSuspend$default (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/v1/core/Column;ZILjava/lang/Object;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers; +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V + public final fun getValue (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor; + public final fun invoke (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun set (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;)V +} + +public final class org/jetbrains/exposed/r2dbc/dao/relationships/SuspendReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass;)V + public final fun getFactory ()Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun provideDelegate (Lorg/jetbrains/exposed/r2dbc/dao/R2dbcEntity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor; +} + diff --git a/exposed-dao-r2dbc/build.gradle.kts b/exposed-dao-r2dbc/build.gradle.kts new file mode 100644 index 0000000000..b82255303b --- /dev/null +++ b/exposed-dao-r2dbc/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") + + alias(libs.plugins.dokka) +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":exposed-core")) + api(project(":exposed-r2dbc")) + + api(libs.r2dbc.spi) + api(libs.kotlinx.coroutines.reactive) + + implementation(libs.slf4j) + + compileOnly(libs.r2dbc.postgresql) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +tasks.withType().configureEach { + targetCompatibility = "17" +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt new file mode 100644 index 0000000000..df0eb82251 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt @@ -0,0 +1,12 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +abstract class R2dbcIntEntity(id: EntityID) : R2dbcEntity(id) + +abstract class R2dbcIntEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LinkedIdentityHashSet.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LinkedIdentityHashSet.kt new file mode 100644 index 0000000000..847c1ee78c --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LinkedIdentityHashSet.kt @@ -0,0 +1,75 @@ +package org.jetbrains.exposed.r2dbc.dao + +import java.util.* + +internal class LinkedIdentityHashSet : MutableSet { + private val set: MutableSet = Collections.newSetFromMap(IdentityHashMap()) + private val list: MutableList = LinkedList() + + override fun add(element: T): Boolean { + return set.add(element).also { if (it) list.add(element) } + } + + override fun addAll(elements: Collection): Boolean { + val toAdd = elements.filter { it !in set } // Maintain order + if (toAdd.isEmpty()) return false + set.addAll(toAdd) + list.addAll(toAdd) + return true + } + + override fun clear() { + set.clear() + list.clear() + } + + override fun iterator(): MutableIterator { + return object : MutableIterator { + private val delegate = list.iterator() + private var current: T? = null + + override fun hasNext() = delegate.hasNext() + + override fun next() = delegate.next().also { + current = it + } + + override fun remove() { + val p = checkNotNull(current) + this@LinkedIdentityHashSet.remove(p) + current = null + } + } + } + + override fun remove(element: T): Boolean { + return set.remove(element).also { if (it) list.remove(element) } + } + + override fun removeAll(elements: Collection): Boolean { + val toRemove = set intersect elements + if (toRemove.isEmpty()) return false + set.removeAll(toRemove) + list.removeAll(toRemove) + return true + } + + override fun retainAll(elements: Collection): Boolean { + return removeAll(set subtract elements) + } + + override val size: Int + get() = set.size + + override fun contains(element: T): Boolean { + return set.contains(element) + } + + override fun containsAll(elements: Collection): Boolean { + return set.containsAll(elements) + } + + override fun isEmpty(): Boolean { + return set.isEmpty() + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt new file mode 100644 index 0000000000..ffa934b7b8 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt @@ -0,0 +1,12 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +abstract class R2dbcLongEntity(id: EntityID) : R2dbcEntity(id) + +abstract class R2dbcLongEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcDaoEntityID.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcDaoEntityID.kt new file mode 100644 index 0000000000..72695b671d --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcDaoEntityID.kt @@ -0,0 +1,10 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +/** + * [EntityID] implementation for R2DBC DAOs. + * This is the R2DBC equivalent of [org.jetbrains.exposed.v1.dao.DaoEntityID]. + */ +class R2dbcDaoEntityID(id: T?, table: IdTable) : EntityID(table, id) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt new file mode 100644 index 0000000000..7eeb5a6dd4 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt @@ -0,0 +1,198 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException +import org.jetbrains.exposed.v1.core.AutoIncColumnType +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.EntityIDColumnType +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.update +import kotlin.collections.get +import kotlin.properties.Delegates +import kotlin.reflect.KProperty + +open class R2dbcEntity(val id: EntityID) { + + /** The associated [R2dbcEntityClass] that manages this [R2dbcEntity] instance. */ + var klass: R2dbcEntityClass> by Delegates.notNull() + internal set + + /** The [R2dbcDatabase] associated with the record mapped to this [R2dbcEntity] instance. */ + var db: R2dbcDatabase by Delegates.notNull() + internal set + + val writeValues = LinkedHashMap, Any?>() + + @Suppress("VariableNaming") + var _readValues: ResultRow? = null + + val readValues: ResultRow + get() = _readValues ?: error("Entity is not initialized yet. Call flush() or reload the entity from the database.") + + private val referenceCache by lazy { HashMap, Any?>() } + + operator fun Column.getValue(o: R2dbcEntity, desc: KProperty<*>): T = lookup() + + fun Column.lookup(): T = when { + writeValues.containsKey(this as Column) -> writeValues[this as Column] as T + id._value == null && _readValues?.hasValue(this)?.not() ?: true -> { + when { + isDatabaseGenerated() -> error( + "Cannot access database-generated column $name before flush. " + + "Call suspend flush() first to retrieve generated values." + ) + else -> defaultValueFun?.invoke() as T + } + } + else -> _readValues?.get(this) as T + } + + operator fun Column.setValue(entity: R2dbcEntity, desc: KProperty<*>, value: T) { + val currentValue = _readValues?.getOrNull(this) + if (writeValues.containsKey(this as Column) || currentValue != value) { + val entityCache = TransactionManager.current().entityCache + + val valueTypeMismatch = value is EntityID<*> && value.table is CompositeIdTable && this.columnType !is EntityIDColumnType<*> + writeValues[this as Column] = if (valueTypeMismatch) (value as EntityID<*>)._value else value + + if (entity.id._value != null) { + @Suppress("UNCHECKED_CAST") + val entityTable = this.table as? IdTable ?: klass.table as IdTable + if (entityCache.data[entityTable].orEmpty().contains(entity.id._value)) { + entityCache.scheduleUpdate(klass, entity) + } + } + } + } + + @Suppress("UNCHECKED_CAST") + internal fun writeIdColumnValue(table: IdTable<*>, value: EntityID<*>) { + (value._value as? CompositeID)?.let { id -> + writeCompositeIdColumnValue(table, id) + value._value = null + } ?: run { + writeValues[table.id as Column] = value + } + } + + @Suppress("UNCHECKED_CAST") + private fun writeCompositeIdColumnValue(table: IdTable<*>, id: CompositeID) { + table.idColumns.forEach { column -> + val wrappedIdColumnType = (column.columnType as EntityIDColumnType<*>).idColumn.columnType + if (wrappedIdColumnType !is AutoIncColumnType<*> && column.defaultValueFun == null && column !in id) { + error("Required column $column is not set to composite id") + } + if (column in id) { // so we skip autoincrement columns and autogenerated columns + id[column as Column>]?.let { + writeValues[column as Column] = it + } + } + } + } + + internal suspend fun isNewEntity(): Boolean { + val cache = TransactionManager.current().entityCache + return cache.inserts[klass.table]?.contains(this) ?: false + } + + private fun storeWrittenValues() { + // Move write values to read values + if (_readValues != null) { + for ((c, v) in writeValues) { + _readValues!![c] = v + } + // Clear _readValues if not all columns are loaded + if (klass.dependsOnColumns.any { it.table == klass.table && !_readValues!!.hasValue(it) }) { + _readValues = null + } + } + // Clear write values + writeValues.clear() + } + + @Suppress("ForbiddenComment") + open suspend fun flush(batch: R2dbcEntityBatchUpdate? = null): Boolean { + if (isNewEntity()) { + return false + } + if (writeValues.isNotEmpty()) { + if (batch == null) { + val table = klass.table + + @Suppress("VariableNaming") + val _writeValues = writeValues.toMap() + storeWrittenValues() + + @Suppress("ForbiddenComment") + // TODO: Implement entity change tracking when subscriptions are implemented + table.update({ table.id eq id }) { + for ((c, v) in _writeValues) { + it[c] = v + } + } + // TODO: Implement alertSubscribers when subscriptions are implemented + } else { + batch.addBatch(this) + for ((c, v) in writeValues) { + batch[c] = v + } + storeWrittenValues() + } + + return true + } + return false + } + + internal fun hasInReferenceCache(ref: Column<*>): Boolean { + return ref in referenceCache + } + + internal fun getReferenceFromCache(ref: Column<*>): T { + return referenceCache[ref] as T + } + + internal fun storeReferenceInCache(ref: Column<*>, value: Any?) { + if (db.config.keepLoadedReferencesOutOfTransaction) { + referenceCache[ref] = value + } + } + + open suspend fun refresh(flush: Boolean = false) { + val transaction = TransactionManager.current() + val cache = transaction.entityCache + + val isNewEntity = isNewEntity() + when { + isNewEntity && flush -> cache.flushInserts(klass.table) + flush -> flush() + isNewEntity -> throw R2dbcEntityNotFoundException(this.id, this.klass) + else -> writeValues.clear() + } + + klass.removeFromCache(this) + val reloaded = klass[id] + cache.store(this) + _readValues = reloaded.readValues + db = transaction.db + } +} + +class R2dbcEntityBatchUpdate { + private val entities = mutableListOf>() + private val values = mutableMapOf, Any?>() + + internal fun addBatch(entity: R2dbcEntity<*>) { + entities.add(entity) + } + + operator fun set(column: Column, value: Any?) { + values[column] = value + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt new file mode 100644 index 0000000000..71304a44be --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt @@ -0,0 +1,183 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.transactions.transactionScope +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.insert +import java.util.concurrent.ConcurrentHashMap + +val R2dbcTransaction.entityCache: R2dbcEntityCache by transactionScope { + R2dbcEntityCache(this as R2dbcTransaction) +} + +class R2dbcEntityCache(private val transaction: R2dbcTransaction) { + val data = ConcurrentHashMap, MutableMap>>() + + @Volatile + private var flushingEntities = false + + internal val inserts = ConcurrentHashMap, MutableSet>>() + + internal val updates = ConcurrentHashMap, MutableSet>>() + + internal val referrers = ConcurrentHashMap, MutableMap, Any>>() + + var maxEntitiesToStore = transaction.db.config.maxEntitiesToStoreInCachePerEntity + + fun > find(entityClass: R2dbcEntityClass, id: EntityID): T? { + val map = data[entityClass.table] ?: return null + return map[id.value] as T? + ?: inserts[entityClass.table]?.firstOrNull { it.id == id } as? T + ?: initializingEntities.firstOrNull { it.klass == entityClass && it.id == id } as? T + } + + fun store(entity: R2dbcEntity) { + val map = data.getOrPut(entity.klass.table) { ConcurrentHashMap() } + map[entity.id.value] = entity + } + + fun remove(table: IdTable, entity: R2dbcEntity) { + data[table]?.remove(entity.id.value) + } + + fun scheduleUpdate(klass: R2dbcEntityClass>, entity: R2dbcEntity) { + updates.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) + } + + fun > findAll(entityClass: R2dbcEntityClass): List { + val map = data[entityClass.table] ?: return emptyList() + return map.values.toList() as List + } + + private val initializingEntities = LinkedIdentityHashSet>() + + val pendingInitializationLambdas = ConcurrentHashMap, MutableList) -> Unit>>() + + fun isEntityInInitializationState(entity: R2dbcEntity): Boolean { + return initializingEntities.contains(entity) + } + + fun addNotInitializedEntityToQueue(entity: R2dbcEntity) { + require(initializingEntities.add(entity)) { "Entity ${entity::class.simpleName} already in initialization process" } + } + + fun finishEntityInitialization(entity: R2dbcEntity) { + require(initializingEntities.lastOrNull() == entity) { + "Can't finish initialization for entity ${entity::class.simpleName} - the initialization order is broken" + } + initializingEntities.remove(entity) + } + + fun scheduleInsert(klass: R2dbcEntityClass>, entity: R2dbcEntity) { + inserts.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) + } + + suspend fun getOrPutReferrers( + column: Column<*>, + sourceId: EntityID<*>, + refs: suspend () -> Any + ): Any { + val columnReferrers = referrers.getOrPut(column) { ConcurrentHashMap() } + return columnReferrers.getOrPut(sourceId) { refs() } + } + + fun removeReferrer(column: Column<*>, entityId: EntityID<*>) { + referrers[column]?.remove(entityId) + } + + suspend fun updateEntities(table: IdTable) { + val entitiesToUpdate = updates.remove(table)?.toList().orEmpty() + if (entitiesToUpdate.isEmpty()) return + + @Suppress("ForbiddenComment") + // TODO: Implement proper batch update execution + for (entity in entitiesToUpdate) { + entity.flush(null) + } + } + + suspend fun flush() { + val toFlush = when { + inserts.isEmpty() && updates.isEmpty() -> emptyList() + inserts.isNotEmpty() && updates.isNotEmpty() -> inserts.keys + updates.keys + inserts.isNotEmpty() -> inserts.keys + else -> updates.keys + } + flush(toFlush) + } + + suspend fun flush(tables: Iterable>) { + if (flushingEntities) { + return + } + + try { + flushingEntities = true + + val insertedTables = inserts.keys + val updateBeforeInsert = SchemaUtils.sortTablesByReferences(insertedTables).filterIsInstance>() + for (table in updateBeforeInsert) { + updateEntities(table) + } + + val tablesToInsert = SchemaUtils.sortTablesByReferences(insertedTables).filterIsInstance>() + for (table in tablesToInsert) { + flushInserts(table) + } + + val updateTheRestTables = tables.toSet() - updateBeforeInsert.toSet() + for (t in updateTheRestTables) { + updateEntities(t) + } + + if (insertedTables.isNotEmpty()) { + removeTablesReferrers(insertedTables) + } + } finally { + flushingEntities = false + } + } + + fun removeTablesReferrers(tables: Collection>) { + val columnsToRemove = referrers.keys.filter { column -> + tables.any { table -> column.table == table } + } + columnsToRemove.forEach { column -> + referrers.remove(column) + } + } + + internal suspend fun flushInserts(table: IdTable) { + val entitiesToInsert = inserts.remove(table)?.toList().orEmpty() + if (entitiesToInsert.isEmpty()) return + + for (entity in entitiesToInsert) { + val entityId = entity.id + val writeValues = entity.writeValues.toMap() + + val insertStatement = table.insert { + for ((column, value) in writeValues) { + @Suppress("UNCHECKED_CAST") + it[column as Column] = value + } + } + + val resultRow = insertStatement.resultedValues?.firstOrNull() + + if (resultRow != null) { + @Suppress("UNCHECKED_CAST") + val generatedId = resultRow[table.id] as EntityID + if (entityId._value == null) { + entityId._value = generatedId.value + } + entity._readValues = resultRow + } + + entity.writeValues.clear() + store(entity) + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt new file mode 100644 index 0000000000..8667c69188 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -0,0 +1,129 @@ +package org.jetbrains.exposed.r2dbc.dao + +import kotlinx.coroutines.flow.firstOrNull +import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnSet +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.Query +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KFunction +import kotlin.reflect.full.primaryConstructor + +abstract class R2dbcEntityClass>( + val table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> T)? = null, +) { + internal val klass: Class<*> = entityType ?: javaClass.enclosingClass as Class + + private val entityPrimaryCtor: KFunction by lazy { klass.kotlin.primaryConstructor as KFunction } + + private val entityCtor: (EntityID) -> T = entityCtor ?: { entityID -> entityPrimaryCtor.call(entityID) } + + open fun new(init: T.() -> Unit) = new(null, init) + + open val dependsOnTables: ColumnSet get() = table + + open val dependsOnColumns: List> get() = dependsOnTables.columns + + open fun new(id: ID?, init: T.() -> Unit): T { + val entityId = if (id == null && table.id.defaultValueFun != null) { + table.id.defaultValueFun!!() + } else { + R2dbcDaoEntityID(id, table) + } + + val entityCache = warmCache() + val prototype: T = createInstance(entityId, null) + prototype.klass = this + prototype.db = TransactionManager.current().db + prototype._readValues = ResultRow.createAndFillDefaults(dependsOnColumns) + if (entityId._value != null) { + prototype.writeIdColumnValue(table, entityId) + } + try { + entityCache.addNotInitializedEntityToQueue(prototype) + prototype.init() + } finally { + entityCache.finishEntityInitialization(prototype) + } + val readValues = prototype._readValues!! + val writeValues = prototype.writeValues + table.columns.filter { col -> + col.defaultValueFun != null && col !in writeValues && readValues.hasValue(col) + }.forEach { col -> + @Suppress("UNCHECKED_CAST") + writeValues[col as Column] = readValues[col] + } + @Suppress("UNCHECKED_CAST") + entityCache.scheduleInsert(this as R2dbcEntityClass>, prototype as R2dbcEntity) + return prototype + } + + protected open fun warmCache(): R2dbcEntityCache = TransactionManager.current().entityCache + + protected open fun createInstance(entityId: EntityID, row: ResultRow?): T = entityCtor(entityId) + + open suspend fun findById(id: EntityID): T? { + val cached = testCache(id) + if (cached != null) return cached + + val row = find { table.id eq id }.firstOrNull() + return row?.let { wrapRow(it) } + } + + fun testCache(id: EntityID): T? = warmCache().find(this, id) + + open fun all(): Query { + warmCache() + return table.selectAll() + } + + fun find(op: Op): Query { + warmCache() + return searchQuery(op) + } + + fun find(op: () -> Op): Query = find(op()) + + open fun searchQuery(op: Op): Query = + dependsOnTables.selectAll().where { op }.notForUpdate() + + @Suppress("MemberVisibilityCanBePrivate") + fun wrapRow(row: ResultRow): T { + val entity = wrap(row[table.id], row) + if (entity._readValues == null) { + entity._readValues = row + } + return entity + } + + fun wrap(id: EntityID, row: ResultRow?): T { + val transaction = TransactionManager.current() + return transaction.entityCache.find(this, id) ?: createInstance(id, row).also { new -> + new.klass = this + new.db = transaction.db + warmCache().store(new) + } + } + + suspend operator fun get(id: EntityID): T = findById(id) ?: throw R2dbcEntityNotFoundException(id, this) + + suspend operator fun get(id: ID): T = get(R2dbcDaoEntityID(id, table)) + + fun removeFromCache(entity: R2dbcEntity) { + val cache = warmCache() + cache.remove(table, entity) + cache.referrers.forEach { (_, referrers) -> + referrers.remove(entity.id) + + // TODO Remove references from other entities to this entity + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt new file mode 100644 index 0000000000..44633f9525 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt @@ -0,0 +1,112 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.AbstractQuery +import org.jetbrains.exposed.v1.core.Key +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.statements.* +import org.jetbrains.exposed.v1.core.targetTables +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor +import org.jetbrains.exposed.v1.r2dbc.statements.api.R2dbcPreparedStatementApi + +class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { + + override fun keepUserDataInTransactionStoreOnCommit(userData: Map, Any?>): Map, Any?> { + return userData.filterValues { it is R2dbcEntityCache } + } + + @Suppress("ComplexMethod") + override suspend fun beforeExecution(transaction: R2dbcTransaction, context: StatementContext) { + beforeExecution(transaction = transaction, context = context, childStatement = null) + } + + private suspend fun beforeExecution(transaction: R2dbcTransaction, context: StatementContext, childStatement: Statement<*>?) { + when (val statement = childStatement ?: context.statement) { + is AbstractQuery<*> -> transaction.flushEntities(statement) + + is ReturningStatement -> { + beforeExecution(transaction = transaction, context = context, childStatement = statement.mainStatement) + } + + is DeleteStatement -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + } + + is UpsertStatement<*>, is BatchUpsertStatement -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(statement.targets.filterIsInstance>()) + } + + is InsertStatement<*> -> { + transaction.flushCache() + if (statement.table is IdTable<*>) { + transaction.entityCache.removeTablesReferrers(listOf(statement.table as IdTable<*>)) + } + } + + is UpdateStatement -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + } + + else -> { + if (statement.type.group == StatementGroup.DDL) transaction.flushCache() + } + } + } + + @Suppress("ForbiddenComment") + override suspend fun afterExecution( + transaction: R2dbcTransaction, + contexts: List, + executedStatement: R2dbcPreparedStatementApi + ) { + // TODO: Implement alertSubscribers when subscriptions are implemented + } + + @Suppress("ForbiddenComment") + override suspend fun beforeCommit(transaction: R2dbcTransaction) { + transaction.flushCache() + // TODO: Implement alertSubscribers and EntityCache.invalidateGlobalCaches when subscriptions are implemented + } + + override suspend fun beforeRollback(transaction: R2dbcTransaction) { + val entityCache = transaction.entityCache + // Clear referrers cache + entityCache.referrers.clear() + + // Clear writeValues and readValues for all entities before clearing the cache to prevent + // stale data from being carried over into a new transaction + entityCache.data.values.forEach { entityMap -> + entityMap.values.forEach { entity -> + entity.writeValues.clear() + entity._readValues = null + } + } + entityCache.updates.values.forEach { entitySet -> + entitySet.forEach { entity -> + entity.writeValues.clear() + entity._readValues = null + } + } + + entityCache.data.clear() + entityCache.inserts.clear() + entityCache.updates.clear() + } + + private suspend fun R2dbcTransaction.flushEntities(query: AbstractQuery<*>) { + // Flush data before executing query or results may be unpredictable + val tables = query.targets.filterIsInstance(IdTable::class.java).toSet() + entityCache.flush(tables) + } +} + +// Extension functions for R2dbcTransaction +suspend fun R2dbcTransaction.flushCache(): List> { + entityCache.flush() + @Suppress("ForbiddenComment") + // TODO: Return list of created entities when entity change tracking is implemented + return emptyList() +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt new file mode 100644 index 0000000000..7d11ed01ed --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt @@ -0,0 +1,7 @@ +package org.jetbrains.exposed.r2dbc.dao.exceptions + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class R2dbcEntityNotFoundException(val id: EntityID<*>, val entity: R2dbcEntityClass<*, *>) : + Exception("Entity ${entity.klass.simpleName}, id=$id not found in the database") diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt new file mode 100644 index 0000000000..c8ae0fe9cc --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt @@ -0,0 +1,50 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.v1.core.Column +import kotlin.reflect.KProperty + +class R2dbcBackReference, ChildID : Any, in Child : R2dbcEntity, REF>( + reference: Column, + factory: R2dbcEntityClass +) { + internal val delegate = R2dbcReferrers( + reference, + factory, + cache = true + ) + + operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent { + thisRef.id.value + + val referrersLambda = delegate.getValue(thisRef, property) + + return suspend { + val referrers = referrersLambda() + referrers.single() + } + } +} + +class R2dbcOptionalBackReference, ChildID : Any, in Child : R2dbcEntity, REF>( + reference: Column, + factory: R2dbcEntityClass +) { + internal val delegate = R2dbcReferrers( + reference, + factory, + cache = true + ) + + operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent? { + thisRef.id.value + + val referrersLambda = delegate.getValue(thisRef, property) + + return suspend { + val referrers = referrersLambda() + referrers.singleOrNull() + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt new file mode 100644 index 0000000000..d96327a97a --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt @@ -0,0 +1,81 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KProperty + +class R2dbcReferrers, ChildID : Any, out Child : R2dbcEntity, REF>( + val reference: Column, + val factory: R2dbcEntityClass, + val cache: Boolean +) { + init { + // Validate that reference column points to the parent entity's table + val referee = reference.referee ?: error("Column $reference is not a reference") + + // Validate that reference column is on the child entity's table + if (factory.table != reference.table) { + error("Column $reference and factory ${factory.table.tableName} point to different tables") + } + } + + @Suppress("NestedBlockDepth") + operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> List { + // Return a suspend lambda that will load the referrers when invoked + return { + // Check if entity ID is available + if (thisRef.id._value == null) { + // TODO should it be error? + emptyList() + } else { + val transaction = TransactionManager.currentOrNull() + + // Out-of-transaction access: return cached data + if (transaction == null) { + if (thisRef.hasInReferenceCache(reference)) { + val cached = thisRef.getReferenceFromCache(reference) + when (cached) { + is List<*> -> cached as List + null -> emptyList() + else -> error("Cached referrer has unexpected type: ${cached::class}") + } + } else { + error("No transaction in context, and referrers not in entity cache for $reference") + } + } else { + // Get the parent entity's ID value to use in query + val referee = reference.referee!! + + @Suppress("UNCHECKED_CAST") + val refValue = with(thisRef) { + referee.lookup() + } as REF + + // Build the query for child entities + val query: suspend () -> List = { + val resultRows = factory.find { reference eq refValue }.toList() + resultRows.map { factory.wrapRow(it) } + } + + // Execute query with caching if enabled + val result = if (cache) { + @Suppress("UNCHECKED_CAST") + transaction.entityCache.getOrPutReferrers(reference, thisRef.id, query) as List + } else { + query() + } + + // Store in entity's reference cache for out-of-transaction access + thisRef.storeReferenceInCache(reference, result) + + result + } + } + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt new file mode 100644 index 0000000000..cf8d63baaf --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt @@ -0,0 +1,78 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.v1.core.Column +import kotlin.reflect.KProperty + +class SuspendReference, REF : Any>( + val reference: Column, + val factory: R2dbcEntityClass +) { + operator fun > provideDelegate( + thisRef: SRC, + property: KProperty<*> + ): SuspendAccessor { + return SuspendAccessor(reference, factory, thisRef) + } +} + +class OptionalSuspendReference, REF : Any>( + val reference: Column, + val factory: R2dbcEntityClass +) { + operator fun > provideDelegate( + thisRef: SRC, + property: KProperty<*> + ): OptionalSuspendAccessor { + return OptionalSuspendAccessor(reference, factory, thisRef) + } +} + +infix fun , REF : Any> R2dbcEntityClass.referencedOnSuspend( + reference: Column +): SuspendReference { + return SuspendReference(reference, this) +} + +infix fun , REF : Any> R2dbcEntityClass.optionalReferencedOnSuspend( + reference: Column +): OptionalSuspendReference { + return OptionalSuspendReference(reference, this) +} + +fun , ChildID : Any, Child : R2dbcEntity, REF> R2dbcEntityClass.referrersOnSuspend( + reference: Column, + cache: Boolean = true +): R2dbcReferrers { + return R2dbcReferrers(reference, this, cache) +} + +infix fun , ChildID : Any, Child : R2dbcEntity, REF> R2dbcEntityClass.referrersOnSuspend( + reference: Column +): R2dbcReferrers { + return referrersOnSuspend(reference, cache = true) +} + +fun , ChildID : Any, Child : R2dbcEntity, REF : Any> + R2dbcEntityClass.optionalReferrersOnSuspend( + reference: Column, + cache: Boolean = true + ): R2dbcReferrers { + return R2dbcReferrers(reference, this, cache) +} + +infix fun , ChildID : Any, Child : R2dbcEntity, REF : Any> + R2dbcEntityClass.optionalReferrersOnSuspend(reference: Column): R2dbcReferrers { + return optionalReferrersOnSuspend(reference, cache = true) +} + +infix fun , ChildID : Any, Child : R2dbcEntity, REF> + R2dbcEntityClass.backReferencedOnSuspend(reference: Column): R2dbcBackReference { + return R2dbcBackReference(reference, this) +} + +infix fun , ChildID : Any, Child : R2dbcEntity, REF> + R2dbcEntityClass.optionalBackReferencedOnSuspend(reference: Column): R2dbcOptionalBackReference { + return R2dbcOptionalBackReference(reference, this) +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt new file mode 100644 index 0000000000..e4906ee8d9 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt @@ -0,0 +1,161 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.collections.get +import kotlin.reflect.KProperty + +class SuspendAccessor, REF : Any>( + private val reference: Column, + private val factory: R2dbcEntityClass, + private val entity: R2dbcEntity<*> +) { + /** + * getValue operator - returns this accessor which has invoke() and set operations. + * + * This enables both patterns: + * - `person.city()` - Async get via invoke() + * - `person.city set cityEntity` - Sync set via infix operator + */ + operator fun getValue(thisRef: Any?, property: KProperty<*>): SuspendAccessor { + return this + } + + /** + * Infix operator for setting the relationship. + * + * Usage: `person.city set newCity` + */ + infix fun set(value: Parent) { + // Validate entities are from same database + if (entity.db != value.db) { + error("Cannot link entities from different databases") + } + + // Store the reference value - extract from the referenced column + @Suppress("UNCHECKED_CAST") + val refValue = when { + reference.referee == factory.table.id -> { + // Reference points to the primary key - use the entity's ID + value.id as REF + } + reference.referee?.table == factory.table -> { + // Reference points to another column in the entity's table + val refereeColumn = reference.referee!! + // Try to get value from writeValues first, then readValues + (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + } + + entity.writeValues[reference as Column] = refValue + + // Schedule update if entity has been flushed + if (entity.id._value != null) { + val entityCache = TransactionManager.current().entityCache + + @Suppress("UNCHECKED_CAST") + val entityTable = reference.table as? IdTable ?: entity.klass.table as IdTable + val contains = entityCache.data[entityTable].orEmpty().contains(entity.id._value) + if (contains) { + @Suppress("UNCHECKED_CAST") + entityCache.scheduleUpdate(entity.klass as R2dbcEntityClass>, entity as R2dbcEntity) + } + } + + // Store in reference cache + entity.storeReferenceInCache(reference, value) + } + + suspend operator fun invoke(): Parent { + @Suppress("ForbiddenComment") + // TODO: Implement reference loading similar to OptionalSuspendAccessor + TODO("Not yet implemented") + } +} + +class OptionalSuspendAccessor, REF : Any>( + private val reference: Column, + private val factory: R2dbcEntityClass, + private val entity: R2dbcEntity<*> +) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): OptionalSuspendAccessor { + return this + } + + infix fun set(value: Parent?) { + if (value != null) { + // Validate entities are from same database + if (entity.db != value.db) { + error("Cannot link entities from different databases") + } + + // Store the reference value - extract from the referenced column + @Suppress("UNCHECKED_CAST") + val refValue = when { + reference.referee == factory.table.id -> { + // Reference points to the primary key - use the entity's ID + value.id as REF + } + reference.referee?.table == factory.table -> { + // Reference points to another column in the entity's table + val refereeColumn = reference.referee!! + // Try to get value from writeValues first, then readValues + (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + } + + entity.writeValues[reference as Column] = refValue + } else { + // Clear the reference + entity.writeValues[reference as Column] = null + } + + // Schedule update if entity has been flushed + if (entity.id._value != null) { + val entityCache = TransactionManager.current().entityCache + + @Suppress("UNCHECKED_CAST") + val entityTable = reference.table as? IdTable ?: entity.klass.table as IdTable + val contains = entityCache.data[entityTable].orEmpty().contains(entity.id._value) + if (contains) { + @Suppress("UNCHECKED_CAST") + entityCache.scheduleUpdate(entity.klass as R2dbcEntityClass>, entity as R2dbcEntity) + } + } + + entity.storeReferenceInCache(reference, value) + } + + suspend operator fun invoke(): Parent? { + if (entity.hasInReferenceCache(reference)) { + return entity.getReferenceFromCache(reference) + } + + @Suppress("UNCHECKED_CAST") + val refValue: REF? = (entity.writeValues[reference as Column] as? REF) + ?: (entity._readValues?.let { row -> row[reference] } as? REF) + + if (refValue == null) { + entity.storeReferenceInCache(reference, null) + return null + } + + @Suppress("UNCHECKED_CAST") + val parentId = when { + refValue is EntityID<*> && reference.referee == factory.table.id -> refValue as EntityID + else -> error("Reference column ${reference.name} does not point to ${factory.table.id}") + } + + val parentEntity = factory.findById(parentId) + entity.storeReferenceInCache(reference, parentEntity) + + return parentEntity + } +} diff --git a/exposed-dao-r2dbc/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor b/exposed-dao-r2dbc/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor new file mode 100644 index 0000000000..460e99854e --- /dev/null +++ b/exposed-dao-r2dbc/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor @@ -0,0 +1 @@ +org.jetbrains.exposed.r2dbc.dao.R2dbcEntityLifecycleInterceptor diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/AliasesTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/AliasesTests.kt index 2abdfa18db..02bdc1e265 100644 --- a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/AliasesTests.kt +++ b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/AliasesTests.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.toList import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.LongIdTable import org.jetbrains.exposed.v1.core.dao.id.UuidTable @@ -14,19 +16,35 @@ import org.jetbrains.exposed.v1.r2dbc.insertAndGetId import org.jetbrains.exposed.v1.r2dbc.select import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.withCitiesAndUsers -import org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.entities.EntityTestsData import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase import org.jetbrains.exposed.v1.r2dbc.tests.TestDB import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals import org.junit.jupiter.api.Test import java.math.BigDecimal +import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class AliasesTests : R2dbcDatabaseTestsBase() { + object YTable : IdTable("YTable") { + override val id: Column> = varchar("uuid", 36).entityId().clientDefault { + EntityID(UUID.randomUUID().toString(), YTable) + } + + val x = bool("x").default(true) + + override val primaryKey = PrimaryKey(id) + } + + object XTable : IntIdTable("XTable") { + val b1 = bool("b1").default(true) + val b2 = bool("b2").default(false) + val y1 = optReference("y1", YTable) + } + @Test fun test_github_issue_379_count_alias_ClassCastException() { val stables = object : UuidTable("Stables") { @@ -110,24 +128,24 @@ class AliasesTests : R2dbcDatabaseTestsBase() { @Test fun `test aliased expression with aliased query`() { - withTables(EntityTestsData.XTable, EntityTestsData.YTable) { + withTables(XTable, YTable) { val dataToInsert = listOf(true, true, false, true) // Oracle throws: Batch execution returning generated values is not supported - EntityTestsData.XTable.batchInsert(dataToInsert, shouldReturnGeneratedValues = false) { - this[EntityTestsData.XTable.b1] = it + XTable.batchInsert(dataToInsert, shouldReturnGeneratedValues = false) { + this[XTable.b1] = it } - val aliasedExpression = EntityTestsData.XTable.id.max().alias("maxId") - val aliasedQuery = EntityTestsData.XTable - .select(EntityTestsData.XTable.b1, aliasedExpression) - .groupBy(EntityTestsData.XTable.b1) + val aliasedExpression = XTable.id.max().alias("maxId") + val aliasedQuery = XTable + .select(XTable.b1, aliasedExpression) + .groupBy(XTable.b1) .alias("maxBoolean") - val aliasedBool = aliasedQuery[EntityTestsData.XTable.b1] + val aliasedBool = aliasedQuery[XTable.b1] val expressionToCheck = aliasedQuery[aliasedExpression] assertEquals("maxBoolean.maxId", expressionToCheck.toString()) val resultQuery = aliasedQuery - .leftJoin(EntityTestsData.XTable, { this[aliasedExpression] }, { id }) + .leftJoin(XTable, { this[aliasedExpression] }, { id }) .select(aliasedBool, expressionToCheck) val result = resultQuery.map { @@ -140,11 +158,11 @@ class AliasesTests : R2dbcDatabaseTestsBase() { @Test fun `test alias for same table with join`() { - withTables(EntityTestsData.XTable, EntityTestsData.YTable) { - val table1Count = EntityTestsData.XTable.id.max().alias("t1max") - val table2Count = EntityTestsData.XTable.id.max().alias("t2max") - val t1Alias = EntityTestsData.XTable.select(table1Count).groupBy(EntityTestsData.XTable.b1).alias("t1") - val t2Alias = EntityTestsData.XTable.select(table2Count).groupBy(EntityTestsData.XTable.b1).alias("t2") + withTables(XTable, YTable) { + val table1Count = XTable.id.max().alias("t1max") + val table2Count = XTable.id.max().alias("t2max") + val t1Alias = XTable.select(table1Count).groupBy(XTable.b1).alias("t1") + val t2Alias = XTable.select(table2Count).groupBy(XTable.b1).alias("t2") t1Alias.join(t2Alias, JoinType.INNER) { t1Alias[table1Count] eq t2Alias[table2Count] }.select(t1Alias[table1Count]).toList() diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/entities/EntityTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/entities/EntityTests.kt deleted file mode 100644 index 48896412c7..0000000000 --- a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/entities/EntityTests.kt +++ /dev/null @@ -1,78 +0,0 @@ -@file:Suppress("MatchingDeclarationName", "Filename") - -package org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.entities - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.dao.id.EntityID -import org.jetbrains.exposed.v1.core.dao.id.IdTable -import org.jetbrains.exposed.v1.core.dao.id.IntIdTable -import java.util.* - -object EntityTestsData { - - object YTable : IdTable("YTable") { - override val id: Column> = varchar("uuid", 36).entityId().clientDefault { - EntityID(UUID.randomUUID().toString(), YTable) - } - - val x = bool("x").default(true) - - override val primaryKey = PrimaryKey(id) - } - - object XTable : IntIdTable("XTable") { - val b1 = bool("b1").default(true) - val b2 = bool("b2").default(false) - val y1 = optReference("y1", YTable) - } - -// class XEntity(id: EntityID) : Entity(id) { -// var b1 by XTable.b1 -// var b2 by XTable.b2 -// -// companion object : EntityClass(XTable) -// } - - enum class XType { - A, B - } - -// open class AEntity(id: EntityID) : IntEntity(id) { -// var b1 by XTable.b1 -// -// companion object : IntEntityClass(XTable) { -// fun create(b1: Boolean, type: XType): AEntity { -// val init: AEntity.() -> Unit = { -// this.b1 = b1 -// } -// val answer = when (type) { -// XType.B -> BEntity.create { init() } -// else -> new { init() } -// } -// return answer -// } -// } -// } - -// class BEntity(id: EntityID) : AEntity(id) { -// var b2 by XTable.b2 -// var y by YEntity optionalReferencedOn XTable.y1 -// -// companion object : IntEntityClass(XTable) { -// fun create(init: AEntity.() -> Unit): BEntity { -// val answer = new { -// init() -// } -// return answer -// } -// } -// } - -// class YEntity(id: EntityID) : Entity(id) { -// var x by YTable.x -// val b: BEntity? by BEntity.backReferencedOn(XTable.y1) -// val bOpt by BEntity optionalBackReferencedOn XTable.y1 -// -// companion object : EntityClass(YTable) -// } -} diff --git a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/mappers/ExposedColumnTypeMapper.kt b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/mappers/ExposedColumnTypeMapper.kt index b3837ec682..e19fc9dc8a 100644 --- a/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/mappers/ExposedColumnTypeMapper.kt +++ b/exposed-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/mappers/ExposedColumnTypeMapper.kt @@ -2,6 +2,7 @@ package org.jetbrains.exposed.v1.r2dbc.mappers import io.r2dbc.spi.Statement import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect import kotlin.reflect.KClass @@ -28,7 +29,12 @@ class ExposedColumnTypeMapper : TypeMapper { ): Boolean { when (columnType) { is EntityIDColumnType<*> -> { - return typeMapping.setValue(statement, dialect, columnType.idColumn.columnType, value, index) + val unwrappedValue = if (value is EntityID<*>) { + value._value + } else { + value + } + return typeMapping.setValue(statement, dialect, columnType.idColumn.columnType, unwrappedValue, index) } is ColumnWithTransform<*, *> -> { return typeMapping.setValue(statement, dialect, columnType.delegate, value, index) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt index e712a72840..a46cc60030 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt @@ -97,7 +97,6 @@ object EntityTestsData { } } -@Tag(MISSING_R2DBC_TEST) @Suppress("LargeClass") class EntityTests : DatabaseTestsBase() { @Test @@ -122,6 +121,8 @@ class EntityTests : DatabaseTestsBase() { b.y = y + println("b.y: ${b.y}") + assertFalse(b.y!!.x) assertNotNull(y.b) } @@ -257,6 +258,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testInsertNonChildWithoutFlush() { withTables(Boards, Posts, Categories) { val board = Board.new { name = "irrelevant" } @@ -266,6 +268,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testThatQueriesWithinOtherQueryIteratorWorksFine() { withTables(Boards, Posts, Categories) { val board1 = Board.new { name = "irrelevant" } @@ -280,6 +283,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testInsertChildWithFlush() { withTables(Boards, Posts, Categories) { val parent = Post.new { this.category = Category.new { title = "title" } } @@ -291,6 +295,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testInsertChildWithChild() { withTables(Boards, Posts, Categories) { val parent = Post.new { this.category = Category.new { title = "title1" } } @@ -304,6 +309,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testOptionalReferrersWithDifferentKeys() { withTables(Boards, Posts, Categories) { val board = Board.new { name = "irrelevant" } @@ -320,6 +326,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testErrorOnSetToDeletedEntity() { withTables(Boards) { expectException { @@ -331,6 +338,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLDelete() { withTables(Boards) { val board1 = Board.new { name = "irrelevant" } @@ -346,6 +354,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLUpdate() { withTables(Boards) { val board1 = Board.new { name = "irrelevant" } @@ -378,6 +387,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLUpsert() { withTables(Items) { testDb -> val oldPrice = 20.0 @@ -417,6 +427,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testDaoFindByIdAndUpdate() { withTables(Items) { val oldPrice = 20.0 @@ -446,6 +457,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testDaoFindSingleByAndUpdate() { withTables(Items) { val oldPrice = 20.0 @@ -504,6 +516,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testOneToOneReference() { withTables(Humans, Users) { val user = User.create("testUser") @@ -524,6 +537,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) @Timeout(value = 5000, unit = TimeUnit.MILLISECONDS) fun testSelfReferences() { withTables(SelfReferenceTable) { @@ -535,6 +549,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testNonEntityIdReference() { withTables(Posts, Boards, Categories) { val category1 = Category.new { @@ -559,6 +574,7 @@ class EntityTests : DatabaseTestsBase() { // https://github.com/JetBrains/Exposed/issues/439 @Test + @Tag(MISSING_R2DBC_TEST) fun callLimitOnRelationDoesntMutateTheCachedValue() { withTables(Posts, Boards, Categories) { addLogger(StdOutSqlLogger) // this is left in on purpose for flaky tests @@ -586,6 +602,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testOrderByOnEntities() { withTables(Categories) { Categories.deleteAll() @@ -600,6 +617,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testThatUpdateOfInsertedEntitiesGoesBeforeAnInsert() { withTables(Categories, Posts, Boards) { val category1 = Category.new { @@ -655,6 +673,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testNewIdWithGet() { // SQL Server doesn't support an explicit id for auto-increment table withTables(listOf(TestDB.SQLSERVER), Parents, Children) { @@ -675,6 +694,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun `newly created entity flushed successfully`() { withTables(Boards) { val board = Board.new { name = "Board1" }.apply { @@ -689,6 +709,7 @@ class EntityTests : DatabaseTestsBase() { inTopLevelTransaction(null, statement = statement) @Test + @Tag(MISSING_R2DBC_TEST) fun sharingEntityBetweenTransactions() { withTables(Humans) { val human1 = newTransaction { @@ -824,6 +845,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadReferencesOnASizedIterable() { withTables(Regions, Schools) { val region1 = Region.new { @@ -865,6 +887,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testIterationOverSizedIterableWithPreload() { fun HashMap>.assertEachQueryExecutedOnlyOnce() { forEach { (statement, stats) -> @@ -932,6 +955,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadReferencesOnAnEntity() { withTables(Regions, Schools) { val region1 = Region.new { @@ -964,6 +988,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferencesOnASizedIterable() { withTables(Regions, Schools) { val region1 = Region.new { @@ -1004,6 +1029,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferencesOnAnEntity() { withTables(Regions, Schools) { val region1 = Region.new { @@ -1034,6 +1060,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadReferrersOnASizedIterable() { withTables(Regions, Schools, Students) { val region1 = Region.new { @@ -1095,6 +1122,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadReferrersOnAnEntity() { withTables(Regions, Schools, Students) { val region1 = Region.new { @@ -1135,6 +1163,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferrersOnASizedIterable() { withTables(Regions, Schools, Students, Detentions) { val region1 = Region.new { @@ -1183,6 +1212,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadInnerTableLinkOnASizedIterable() { withTables(Regions, Schools, Holidays, SchoolHolidays) { val now = System.currentTimeMillis() @@ -1244,6 +1274,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadInnerTableLinkOnAnEntity() { withTables(Regions, Schools, Holidays, SchoolHolidays) { val now = System.currentTimeMillis() @@ -1303,6 +1334,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadRelationAtDepth() { withTables(Regions, Schools, Holidays, SchoolHolidays, Students, Notes) { val region1 = Region.new { @@ -1346,6 +1378,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadBackReferrenceOnASizedIterable() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1391,6 +1424,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun preloadBackReferrenceOnAnEntity() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1435,6 +1469,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun `test reference cache doesn't fully invalidated on set entity reference`() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1467,6 +1502,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun `test nested entity initialization`() { withTables(Posts, Categories, Boards) { val post = Post.new { @@ -1493,6 +1529,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testExplicitEntityConstructor() { var createBoardCalled = false fun createBoard(id: EntityID): Board { @@ -1527,6 +1564,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testSelectFromStringIdTableWithPrimaryKeyByColumn() { withTables(RequestsTable) { Request.new { @@ -1551,6 +1589,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testDatabaseGeneratedValues() { withTables(excludeSettings = listOf(TestDB.SQLITE), CreditCards) { testDb -> when (testDb) { @@ -1609,6 +1648,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testEntityIdParam() { withTables(CreditCards) { val newCard = CreditCard.new { @@ -1653,6 +1693,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testEagerLoadingWithStringParentId() { withTables(Countries, Dishes, configure = { keepLoadedReferencesOutOfTransaction = true }) { val lebanonId = Countries.insertAndGetId { @@ -1722,6 +1763,7 @@ class EntityTests : DatabaseTestsBase() { * another column that is a unique index. */ @Test + @Tag(MISSING_R2DBC_TEST) fun testEagerLoadingWithReferenceDifferentFromParentId() { withTables(Customers, Orders, configure = { keepLoadedReferencesOutOfTransaction = true }) { val customer1 = Customer.new { @@ -1765,6 +1807,7 @@ class EntityTests : DatabaseTestsBase() { } @Test + @Tag(MISSING_R2DBC_TEST) fun testDifferentEntitiesMappedToTheSameTable() { withTables(TestTable) { val entityA = TestEntityA.new { diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f87f1900e..dd835f95c2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,8 @@ include("exposed-migration-r2dbc") include("exposed-r2dbc") include("exposed-r2dbc-tests") include("exposed-jdbc-r2dbc-tests") +include("exposed-dao-r2dbc") +include("exposed-dao-r2dbc-tests") include("exposed-gradle-plugin") pluginManagement { From 15945fada6dcfca7562cd9b51371560178ce36d9 Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Fri, 10 Apr 2026 10:52:18 +0200 Subject: [PATCH 2/7] feat: review issues --- build.gradle.kts | 14 +++---- .../r2dbc/tests/shared/R2dbcEntityTests.kt | 40 +++++++++---------- .../jetbrains/exposed/r2dbc/dao/IntEntity.kt | 4 +- .../jetbrains/exposed/r2dbc/dao/LongEntity.kt | 4 +- .../exposed/r2dbc/dao/R2dbcEntity.kt | 7 ++-- .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 1 + .../r2dbc/dao/relationships/R2dbcReferrers.kt | 2 +- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 306724637b..6048e33c48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { dokka(projects.exposed.exposedSpringBoot4Starter) dokka(projects.exposed.springTransaction) dokka(projects.exposed.spring7Transaction) + dokka(projects.exposed.exposedDaoR2dbc) // Kover aggregated coverage dependencies // Include all source modules for coverage aggregation @@ -60,6 +61,7 @@ dependencies { kover(project(":exposed-migration-jdbc")) kover(project(":exposed-migration-r2dbc")) kover(project(":exposed-r2dbc")) + kover(project(":exposed-dao-r2dbc")) // Include test modules to ensure their tests are executed and coverage is collected kover(project(":exposed-tests")) @@ -71,14 +73,10 @@ repositories { mavenCentral() } +val unpublishedProjects = setOf("exposed-tests", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc") + allprojects { - if (this.name != "exposed-tests" && - this.name != "exposed-r2dbc-tests" && - this.name != "exposed-jdbc-r2dbc-tests" && - this.name != "exposed-dao-r2dbc-tests" && - this.name != "exposed-dao-r2dbc" && - this != rootProject - ) { + if (this.name !in unpublishedProjects && this != rootProject) { apply(plugin = "com.vanniktech.maven.publish") apply(plugin = "signing") this@allprojects.mavenPublishing { @@ -93,7 +91,7 @@ allprojects { } apiValidation { - ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests")) + ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc")) } subprojects { diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt index ad4f52f6b3..a46ed82b29 100644 --- a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt @@ -1,11 +1,11 @@ package org.jetbrains.exposed.dao.r2dbc.tests.shared +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass -import org.jetbrains.exposed.r2dbc.dao.R2dbcIntEntity -import org.jetbrains.exposed.r2dbc.dao.R2dbcIntEntityClass -import org.jetbrains.exposed.r2dbc.dao.R2dbcLongEntity -import org.jetbrains.exposed.r2dbc.dao.R2dbcLongEntityClass import org.jetbrains.exposed.r2dbc.dao.flushCache import org.jetbrains.exposed.r2dbc.dao.relationships.backReferencedOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.optionalBackReferencedOnSuspend @@ -55,10 +55,10 @@ object EntityTestsData { A, B } - open class AEntity(id: EntityID) : R2dbcIntEntity(id) { + open class AEntity(id: EntityID) : IntR2dbcEntity(id) { var b1 by XTable.b1 - companion object : R2dbcIntEntityClass(XTable) { + companion object : IntR2dbcEntityClass(XTable) { fun create(b1: Boolean, type: XType): AEntity { val init: AEntity.() -> Unit = { this.b1 = b1 @@ -76,7 +76,7 @@ object EntityTestsData { var b2 by XTable.b2 val y by YEntity optionalReferencedOnSuspend XTable.y1 - companion object : R2dbcIntEntityClass(XTable) { + companion object : IntR2dbcEntityClass(XTable) { fun create(init: AEntity.() -> Unit): BEntity { val answer = new { init() @@ -158,14 +158,14 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { } internal object OneAutoFieldTable : IntIdTable("single") - internal class SingleFieldEntity(id: EntityID) : R2dbcIntEntity(id) { - companion object : R2dbcIntEntityClass(OneAutoFieldTable) + internal class SingleFieldR2dbcEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(OneAutoFieldTable) } @Test fun testOneFieldEntity() { withTables(OneAutoFieldTable) { - val new = SingleFieldEntity.new { } + val new = SingleFieldR2dbcEntity.new { } commit() } } @@ -208,15 +208,15 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { val title = varchar("title", 50) } - class Board(id: EntityID) : R2dbcIntEntity(id) { - companion object : R2dbcIntEntityClass(Boards) + class Board(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Boards) var name by Boards.name val posts by Post optionalReferrersOnSuspend Posts.board } - class Post(id: EntityID) : R2dbcLongEntity(id) { - companion object : R2dbcLongEntityClass(Posts) + class Post(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Posts) val board by Board optionalReferencedOnSuspend Posts.board val parent by Post optionalReferencedOnSuspend Posts.parent @@ -224,8 +224,8 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { val optCategory by Category optionalReferencedOnSuspend Posts.optCategory } - class Category(id: EntityID) : R2dbcIntEntity(id) { - companion object : R2dbcIntEntityClass(Categories) + class Category(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Categories) val uniqueId by Categories.uniqueId var title by Categories.title @@ -260,14 +260,14 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { val name = text("name") } - open class Human(id: EntityID) : R2dbcIntEntity(id) { - companion object : R2dbcIntEntityClass(Humans) + open class Human(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Humans) var h by Humans.h } - class User(id: EntityID) : R2dbcIntEntity(id) { - companion object : R2dbcIntEntityClass(Users) { + class User(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) { fun create(name: String): User { val h = Human.new { h = name.take(2) } return User.new(h.id.value) { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt index df0eb82251..9cdd5c840e 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/IntEntity.kt @@ -3,9 +3,9 @@ package org.jetbrains.exposed.r2dbc.dao import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable -abstract class R2dbcIntEntity(id: EntityID) : R2dbcEntity(id) +abstract class IntR2dbcEntity(id: EntityID) : R2dbcEntity(id) -abstract class R2dbcIntEntityClass( +abstract class IntR2dbcEntityClass( table: IdTable, entityType: Class? = null, entityCtor: ((EntityID) -> E)? = null diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt index ffa934b7b8..3a354a7324 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/LongEntity.kt @@ -3,9 +3,9 @@ package org.jetbrains.exposed.r2dbc.dao import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable -abstract class R2dbcLongEntity(id: EntityID) : R2dbcEntity(id) +abstract class LongR2dbcEntity(id: EntityID) : R2dbcEntity(id) -abstract class R2dbcLongEntityClass( +abstract class LongR2dbcEntityClass( table: IdTable, entityType: Class? = null, entityCtor: ((EntityID) -> E)? = null diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt index 7eeb5a6dd4..5ff4540f19 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt @@ -50,7 +50,7 @@ open class R2dbcEntity(val id: EntityID) { else -> defaultValueFun?.invoke() as T } } - else -> _readValues?.get(this) as T + else -> readValues[this] } operator fun Column.setValue(entity: R2dbcEntity, desc: KProperty<*>, value: T) { @@ -101,7 +101,7 @@ open class R2dbcEntity(val id: EntityID) { return cache.inserts[klass.table]?.contains(this) ?: false } - private fun storeWrittenValues() { + fun storeWrittenValues() { // Move write values to read values if (_readValues != null) { for ((c, v) in writeValues) { @@ -119,7 +119,8 @@ open class R2dbcEntity(val id: EntityID) { @Suppress("ForbiddenComment") open suspend fun flush(batch: R2dbcEntityBatchUpdate? = null): Boolean { if (isNewEntity()) { - return false + TransactionManager.current().entityCache.flushInserts(klass.table) + return true } if (writeValues.isNotEmpty()) { if (batch == null) { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt index 8667c69188..17464cd52c 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -117,6 +117,7 @@ abstract class R2dbcEntityClass>( suspend operator fun get(id: ID): T = get(R2dbcDaoEntityID(id, table)) + @Suppress("ForbiddenComment") fun removeFromCache(entity: R2dbcEntity) { val cache = warmCache() cache.remove(table, entity) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt index d96327a97a..c7a929ee6d 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt @@ -24,7 +24,7 @@ class R2dbcReferrers, ChildID } } - @Suppress("NestedBlockDepth") + @Suppress("NestedBlockDepth", "ForbiddenComment") operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> List { // Return a suspend lambda that will load the referrers when invoked return { From 0f18f7a617e4bf3272615bb0ef0f6b0537fdeefb Mon Sep 17 00:00:00 2001 From: obabichevjb <166523824+obabichevjb@users.noreply.github.com> Date: Tue, 19 May 2026 11:29:43 +0200 Subject: [PATCH 3/7] feat: eager loading, many-to-many (#2784) * feat: eager loading, many-to-many * feat: Complete migration of EntityTests * feat: Extract trimToFirst --- .../exposed/v1/core/QueryParameter.kt | 5 +- .../r2dbc/tests/shared/R2dbcEntityTests.kt | 1569 ++++++++++++++++- .../exposed/r2dbc/dao/R2dbcEntity.kt | 61 +- .../exposed/r2dbc/dao/R2dbcEntityCache.kt | 133 +- .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 91 +- .../dao/R2dbcEntityLifecycleInterceptor.kt | 36 +- .../R2dbcEntityNotFoundException.kt | 2 +- .../dao/relationships/R2dbcBackReference.kt | 27 +- .../dao/relationships/R2dbcEagerLoading.kt | 266 +++ .../dao/relationships/R2dbcInnerTableLink.kt | 130 ++ .../r2dbc/dao/relationships/R2dbcReferrers.kt | 19 +- .../R2dbcRelationshipExtensions.kt | 14 + .../dao/relationships/SuspendAccessor.kt | 59 +- .../jetbrains/exposed/v1/dao/EntityCache.kt | 29 +- .../v1/tests/shared/entities/EntityTests.kt | 50 +- 15 files changed, 2363 insertions(+), 128 deletions(-) create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/v1/core/QueryParameter.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/v1/core/QueryParameter.kt index a782ca5780..fea84e5887 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/v1/core/QueryParameter.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/v1/core/QueryParameter.kt @@ -1,6 +1,7 @@ package org.jetbrains.exposed.v1.core import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob import java.math.BigDecimal @@ -14,7 +15,9 @@ class QueryParameter( /** Returns the column type of this expression. */ override val columnType: IColumnType ) : ExpressionWithColumnType() { - internal val compositeValue: CompositeID? = (value as? EntityID<*>)?.value as? CompositeID + internal val compositeValue: CompositeID? = (value as? EntityID<*>) + ?.takeIf { it.table is CompositeIdTable } + ?.value as? CompositeID override fun toQueryBuilder(queryBuilder: QueryBuilder) { queryBuilder { diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt index a46ed82b29..c18c5d781e 100644 --- a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt @@ -1,30 +1,71 @@ package org.jetbrains.exposed.dao.r2dbc.tests.shared +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntity import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException import org.jetbrains.exposed.r2dbc.dao.flushCache import org.jetbrains.exposed.r2dbc.dao.relationships.backReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.load import org.jetbrains.exposed.r2dbc.dao.relationships.optionalBackReferencedOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferrersOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.referrersOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Case import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.StdOutSqlLogger +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.idParam +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.vendors.OracleDialect +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.batchUpsert +import org.jetbrains.exposed.v1.r2dbc.deleteAll +import org.jetbrains.exposed.v1.r2dbc.deleteWhere +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.select +import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.currentDialectTest +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import org.jetbrains.exposed.v1.r2dbc.tests.shared.expectException +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import org.jetbrains.exposed.v1.r2dbc.update +import org.jetbrains.exposed.v1.r2dbc.upsert +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.assertNull import java.util.UUID +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertSame object EntityTestsData { @@ -95,6 +136,7 @@ object EntityTestsData { } } +@Suppress("LargeClass") class R2dbcEntityTests : R2dbcDatabaseTestsBase() { @Test fun testDefaults01() { @@ -192,6 +234,202 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { } } + object Items : IntIdTable("items") { + val name = varchar("name", 255).uniqueIndex() + val price = double("price") + } + + class Item(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Items) + + var name by Items.name + var price by Items.price + } + + @Test + fun testCacheInvalidatedOnDSLUpsert() { + withTables(Items) { testDb -> + val oldPrice = 20.0 + val itemA = Item.new { + name = "Item A" + price = oldPrice + } + assertEquals(oldPrice, itemA.price) + assertNotNull(Item.testCache(itemA.id)) + + val newPrice = 50.0 + val conflictKeys = if (testDb in TestDB.ALL_MYSQL_LIKE) emptyArray>() else arrayOf(Items.name) + Items.upsert(*conflictKeys) { + it[name] = itemA.name + it[price] = newPrice + } + assertEquals(oldPrice, itemA.price) + assertNull(Item.testCache(itemA.id)) + + itemA.refresh(flush = false) + assertEquals(newPrice, itemA.price) + assertNotNull(Item.testCache(itemA.id)) + + val newPricePlusExtra = 100.0 + val newItems = List(5) { i -> "Item ${'A' + i}" to newPricePlusExtra } + Items.batchUpsert(newItems, *conflictKeys, shouldReturnGeneratedValues = false) { (name, price) -> + this[Items.name] = name + this[Items.price] = price + } + assertEquals(newPrice, itemA.price) + assertNull(Item.testCache(itemA.id)) + + itemA.refresh(flush = false) + assertEquals(newPricePlusExtra, itemA.price) + assertNotNull(Item.testCache(itemA.id)) + } + } + + @Test + fun testDaoFindByIdAndUpdate() { + withTables(Items) { + val oldPrice = 20.0 + val item = Item.new { + name = "Item A" + price = oldPrice + } + assertEquals(oldPrice, item.price) + assertNotNull(Item.testCache(item.id)) + + val newPrice = 50.0 + flushCache() + val updatedItem = Item.findByIdAndUpdate(item.id.value) { + it.price = newPrice + } + + assertSame(updatedItem, item) + + assertNotNull(updatedItem) + assertEquals(newPrice, updatedItem.price) + assertNotNull(Item.testCache(item.id)) + + assertEquals(newPrice, item.price) + item.refresh(flush = false) + assertEquals(oldPrice, item.price) + assertNotNull(Item.testCache(item.id)) + } + } + + @Test + fun testDaoFindSingleByAndUpdate() { + withTables(Items) { + val oldPrice = 20.0 + val item = Item.new { + name = "Item A" + price = oldPrice + } + assertEquals(oldPrice, item.price) + assertNotNull(Item.testCache(item.id)) + + val newPrice = 50.0 + val updatedItem = Item.findSingleByAndUpdate(Items.name eq "Item A") { + it.price = newPrice + } + + assertSame(updatedItem, item) + + assertNotNull(updatedItem) + assertEquals(newPrice, updatedItem.price) + assertNotNull(Item.testCache(item.id)) + + assertEquals(newPrice, item.price) + item.refresh(flush = false) + assertEquals(oldPrice, item.price) + assertNotNull(Item.testCache(item.id)) + } + } + + private object SelfReferenceTable : IntIdTable() { + val parentId = optReference("parent", SelfReferenceTable) + } + + class SelfReferencedEntity(id: EntityID) : IntR2dbcEntity(id) { + var parent by SelfReferenceTable.parentId + + companion object : IntR2dbcEntityClass(SelfReferenceTable) + } + + @Test + @Timeout(value = 5000, unit = TimeUnit.MILLISECONDS) + fun testSelfReferences() { + withTables(SelfReferenceTable) { + val ref1 = SelfReferencedEntity.new { } + ref1.parent = ref1.id + val refRow = SelfReferenceTable.selectAll().where { SelfReferenceTable.id eq ref1.id }.single() + assertEquals(ref1.id._value, refRow[SelfReferenceTable.parentId]!!.value) + } + } + + @Test + fun testNonEntityIdReference() { + withTables(Posts, Boards, Categories) { + val category1 = Category.new { + title = "cat1" + } + + val post1 = Post.new { + optCategory set category1 + category set Category.new { title = "title" } + } + + val post2 = Post.new { + optCategory set category1 + parent set post1 + } + + assertEquals(2L, Post.all().count()) + assertEquals(2, category1.posts().count()) + assertEquals(2L, Posts.selectAll().where { Posts.optCategory eq category1.uniqueId }.count()) + } + } + + // https://github.com/JetBrains/Exposed/issues/439 + @Test + fun callLimitOnRelationDoesntMutateTheCachedValue() { + withTables(Posts, Boards, Categories) { + addLogger(StdOutSqlLogger) // this is left in on purpose for flaky tests + val category1 = Category.new { + title = "cat1" + } + + Post.new { + optCategory set category1 + category set Category.new { title = "title" } + } + + Post.new { + optCategory set category1 + } + commit() + + assertEquals(2, category1.posts().count()) + assertEquals(2, category1.posts().toList().size) + assertEquals(1, category1.posts().limit(1).toList().size) + assertEquals(1L, category1.posts().limit(1).count()) + assertEquals(2, category1.posts().count()) + assertEquals(2, category1.posts().toList().size) + } + } + + @Test + fun testOrderByOnEntities() { + withTables(Categories) { + Categories.deleteAll() + val category1 = Category.new { title = "Test1" } + val category3 = Category.new { title = "Test3" } + val category2 = Category.new { title = "Test2" } + + assertEqualLists(listOf(category1, category3, category2), Category.all().toList()) + assertEqualLists(listOf(category1, category2, category3), Category.all().orderBy(Categories.title to SortOrder.ASC).toList()) + assertEqualLists(listOf(category3, category2, category1), Category.all().orderBy(Categories.title to SortOrder.DESC).toList()) + } + } + object Boards : IntIdTable(name = "board") { val name = varchar("name", 255).index(isUnique = true) } @@ -251,6 +489,129 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { } } + @Test + fun testInsertNonChildWithoutFlush() { + withTables(Boards, Posts, Categories) { + val board = Board.new { name = "irrelevant" } + Post.new { this.board set board } // first flush before referencing + // In JDBC DAO, setting a reference triggers an implicit flush of the referenced entity + // (via EntityID.value access). In R2DBC, set is synchronous and cannot trigger a suspend flush, + // so both entities remain in the insert cache. + assertEquals(2, flushCache().size) + } + } + + @Test + fun testThatQueriesWithinOtherQueryIteratorWorksFine() { + withTables(Boards, Posts, Categories) { + val board1 = Board.new { name = "irrelevant" } + val board2 = Board.new { name = "relevant" } + val post1 = Post.new { this.board set board1 } + + Board.all().forEach { + it.posts().count() to it.posts + Post.find { Posts.board eq it.id }.toList() + .map { post -> post.board()?.name.orEmpty() } + .joinToString() + } + } + } + + @Test + fun testInsertChildWithFlush() { + withTables(Boards, Posts, Categories) { + val parent = Post.new { this.category set Category.new { title = "title" } } + flushCache() + assertNotNull(parent.id._value) + Post.new { this.parent set parent } + assertEquals(1, flushCache().size) + } + } + + @Test + fun testInsertChildWithChild() { + withTables(Boards, Posts, Categories) { + val parent = Post.new { this.category set Category.new { title = "title1" } } + val child1 = Post.new { + this.parent set parent + this.category set Category.new { title = "title2" } + } + Post.new { this.parent set child1 } + flushCache() + } + } + + @Test + fun testOptionalReferrersWithDifferentKeys() { + withTables(Boards, Posts, Categories) { + val board = Board.new { name = "irrelevant" } + val post1 = Post.new { + this.board set board + this.category set Category.new { title = "title" } + } + // In R2DBC, entities must be flushed before referrers queries can find them + flushCache() + assertEquals(1, board.posts().count()) + assertEquals(post1, board.posts().single()) + + Post.new { this.board set board } + flushCache() + assertEquals(2, board.posts().count()) + } + } + + @Test + fun testErrorOnSetToDeletedEntity() { + withTables(Boards) { + expectException { + val board = Board.new { name = "irrelevant" } + board.delete() + board.name = "Cool" + } + } + } + + @Test + fun testCacheInvalidatedOnDSLDelete() { + withTables(Boards) { + val board1 = Board.new { name = "irrelevant" } + assertNotNull(Board.testCache(board1.id)) + board1.delete() + assertNull(Board.testCache(board1.id)) + + val board2 = Board.new { name = "irrelevant" } + assertNotNull(Board.testCache(board2.id)) + // In R2DBC, entity IDs are not auto-flushed on access (unlike JDBC DaoEntityID), + // so we must flush before using the ID in DSL operations. + flushCache() + Boards.deleteWhere { Boards.id eq board2.id } + assertNull(Board.testCache(board2.id)) + } + } + + @Test + fun testCacheInvalidatedOnDSLUpdate() { + withTables(Boards) { + val board1 = Board.new { name = "irrelevant" } + assertNotNull(Board.testCache(board1.id)) + board1.name = "relevant" + assertEquals("relevant", board1.name) + + val board2 = Board.new { name = "irrelevant2" } + assertNotNull(Board.testCache(board2.id)) + // In R2DBC, entity IDs are not auto-flushed on access (unlike JDBC DaoEntityID), + // so we must flush before using the ID in DSL operations. + flushCache() + Boards.update({ Boards.id eq board2.id }) { + it[name] = "relevant2" + } + assertNull(Board.testCache(board2.id)) + board2.refresh(flush = false) + assertNotNull(Board.testCache(board2.id)) + assertEquals("relevant2", board2.name) + } + } + object Humans : IntIdTable("human") { val h = text("h", eagerLoading = true) } @@ -279,4 +640,1210 @@ class R2dbcEntityTests : R2dbcDatabaseTestsBase() { val human by Human referencedOnSuspend Users.id var name by Users.name } -} + + @Test + fun testThatUpdateOfInsertedEntitiesGoesBeforeAnInsert() { + withTables(Categories, Posts, Boards) { + val category1 = Category.new { + title = "category1" + } + + val category2 = Category.new { + title = "category2" + } + + val post1 = Post.new { + category set category1 + } + + flushCache() + assertEquals(post1.category(), category1) + + post1.category set category2 + + val post2 = Post.new { + category set category1 + } + + flushCache() + Post.reload(post1) + Post.reload(post2) + + assertEquals(category2, post1.category()) + assertEquals(category1, post2.category()) + } + } + + object Parents : LongIdTable() { + val name = varchar("name", 50) + } + + class Parent(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Parents) + + var name by Parents.name + } + + object Children : LongIdTable() { + val companyId = reference("company_id", Parents) + val name = varchar("name", 80) + } + + class Child(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Children) + + val parent by Parent referencedOnSuspend Children.companyId + var name by Children.name + } + + @Test + fun testNewIdWithGet() { + // SQL Server doesn't support an explicit id for auto-increment table + withTables(listOf(TestDB.SQLSERVER), Parents, Children) { + val parentId = Parent.new { + name = "parent1" + }.also { + flushCache() + }.id.value + + commit() + + val parent = Parent[parentId] + val child = Child.new(100L) { + this.parent set parent + name = "child1" + } + child.flush() + + assertEquals(100L, child.id.value) + assertEquals(parentId, child.parent().id.value) + } + } + + @Test + fun `newly created entity flushed successfully`() { + withTables(Boards) { + val board = Board.new { name = "Board1" }.apply { + assertEquals(true, flush()) + } + + assertEquals("Board1", board.name) + } + } + + private suspend fun newTransaction(statement: suspend R2dbcTransaction.() -> T) = + inTopLevelSuspendTransaction(null, statement = statement) + + @Test + fun sharingEntityBetweenTransactions() { + withTables(Humans) { + val human1 = newTransaction { + maxAttempts = 1 + Human.new { + this.h = "foo" + } + } + newTransaction { + maxAttempts = 1 + assertEquals(null, Human.testCache(human1.id)) + assertEquals("foo", Humans.selectAll().single()[Humans.h]) + // Unlike JDBC, R2DBC's Column.setValue cannot suspendingly probe the DB to adopt + // an entity from another transaction. Explicitly attach it first. + Human.attach(human1) + human1.h = "bar" + assertEquals(human1, Human.testCache(human1.id)) + assertEquals("bar", Humans.selectAll().single()[Humans.h]) + } + + newTransaction { + maxAttempts = 1 + assertEquals("bar", Humans.selectAll().single()[Humans.h]) + } + } + } + + object Regions : IntIdTable(name = "region") { + val name = varchar("name", 255) + } + + object Students : LongIdTable(name = "students") { + val name = varchar("name", 255) + val school = reference("school_id", Schools) + } + + object StudentBios : LongIdTable(name = "student_bio") { + val student = reference("student_id", Students).uniqueIndex() + val dateOfBirth = varchar("date_of_birth", 25) + } + + object Notes : LongIdTable(name = "notes") { + val text = varchar("text", 255) + val student = reference("student_id", Students) + } + + object Detentions : LongIdTable(name = "detentions") { + val reason = varchar("reason", 255) + val student = optReference("student_id", Students) + } + + object Holidays : LongIdTable(name = "holidays") { + val holidayStart = long("holiday_start") + val holidayEnd = long("holiday_end") + } + + object SchoolHolidays : Table(name = "school_holidays") { + val school = reference("school_id", Schools, ReferenceOption.CASCADE, ReferenceOption.CASCADE) + val holiday = reference("holiday_id", Holidays, ReferenceOption.CASCADE, ReferenceOption.CASCADE) + + override val primaryKey = PrimaryKey(school, holiday) + } + + object Schools : IntIdTable(name = "school") { + val name = varchar("name", 255).index(isUnique = true) + val region = reference("region_id", Regions) + val secondaryRegion = optReference("secondary_region_id", Regions) + } + + class Region(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Regions) + + var name by Regions.name + + override fun equals(other: Any?): Boolean { + return (other as? Region)?.id?.equals(id) ?: false + } + + override fun hashCode(): Int = id.hashCode() + } + + abstract class ComparableLongEntity(id: EntityID) : LongR2dbcEntity(id) { + override fun equals(other: Any?): Boolean { + return (other as? T)?.id?.equals(id) ?: false + } + + override fun hashCode(): Int = id.hashCode() + } + + class Student(id: EntityID) : ComparableLongEntity(id) { + companion object : LongR2dbcEntityClass(Students) + + var name by Students.name + val school by School referencedOnSuspend Students.school + val notes by Note.referrersOnSuspend(Notes.student, true) + val detentions by Detention.optionalReferrersOnSuspend(Detentions.student, true) + val bio by StudentBio.optionalBackReferencedOnSuspend(StudentBios.student) + } + + class StudentBio(id: EntityID) : ComparableLongEntity(id) { + companion object : LongR2dbcEntityClass(StudentBios) + + val student by Student.referencedOnSuspend(StudentBios.student) + var dateOfBirth by StudentBios.dateOfBirth + } + + class Note(id: EntityID) : ComparableLongEntity(id) { + companion object : LongR2dbcEntityClass(Notes) + + var text by Notes.text + val student by Student referencedOnSuspend Notes.student + } + + class Detention(id: EntityID) : ComparableLongEntity(id) { + companion object : LongR2dbcEntityClass(Detentions) + + var reason by Detentions.reason + val student by Student optionalReferencedOnSuspend Detentions.student + } + + class Holiday(id: EntityID) : ComparableLongEntity(id) { + companion object : LongR2dbcEntityClass(Holidays) + + var holidayStart by Holidays.holidayStart + var holidayEnd by Holidays.holidayEnd + } + + class School(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Schools) + + var name by Schools.name + val region by Region referencedOnSuspend Schools.region + val secondaryRegion by Region optionalReferencedOnSuspend Schools.secondaryRegion + val students by Student.referrersOnSuspend(Students.school, true) + val holidays by Holiday viaSuspend SchoolHolidays + } + + @Test + fun preloadReferencesOnASizedIterable() { + withTables(Regions, Schools) { + val region1 = Region.new { + name = "United Kingdom" + } + + val region2 = Region.new { + name = "England" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val school2 = School.new { + name = "Harrow" + region set region1 + } + + val school3 = School.new { + name = "Winchester" + region set region2 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + School.all().with(School::region) + assertNotNull(School.testCache(school1.id)) + assertNotNull(School.testCache(school2.id)) + assertNotNull(School.testCache(school3.id)) + + assertEquals(region1, Region.testCache(School.testCache(school1.id)!!.readValues[Schools.region])) + assertEquals(region1, Region.testCache(School.testCache(school2.id)!!.readValues[Schools.region])) + assertEquals(region2, Region.testCache(School.testCache(school3.id)!!.readValues[Schools.region])) + } + } + } + + @Test + fun testIterationOverSizedIterableWithPreload() { + fun HashMap>.assertEachQueryExecutedOnlyOnce() { + forEach { (statement, stats) -> + val executionCount = stats.first + assertEquals(1, executionCount, "Statement executed more than once: $statement") + } + } + + withTables(Regions, Schools) { + val region1 = Region.new { + name = "United Kingdom" + } + School.new { + name = "Eton" + region set region1 + } + School.new { + name = "Harrow" + region set region1 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + debug = true // enables tracking of executed statements in this transaction + + val allSchools = School.all().with(School::region).toList() + + assertEquals(2, allSchools.size) + // expected: 1 query to select all School, and 1 query to select referenced Regions + assertEquals(2, statementCount) + assertEquals(statementCount, statementStats.size) + statementStats.assertEachQueryExecutedOnlyOnce() + + // reset tracker + statementCount = 0 + statementStats.clear() + + val oneSchool = School.all().limit(1).with(School::region).toList() + + assertEquals(1, oneSchool.size) + assertEquals(2, statementCount) + assertEquals(statementCount, statementStats.size) + statementStats.assertEachQueryExecutedOnlyOnce() + + debug = false + } + + // test that cached result doesn't propagate when SizedIterable query changes after loading + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + debug = true + + val oneSchool = School.all().with(School::region).limit(1).toList() + + assertEquals(1, oneSchool.size) + // expected: 1 query to select all School, 1 query to select the referenced Regions, + // then 1 new query to select only first School + assertEquals(3, statementCount) + assertEquals(statementCount, statementStats.size) + statementStats.assertEachQueryExecutedOnlyOnce() + + debug = false + } + } + } + + @Test + fun preloadReferencesOnAnEntity() { + withTables(Regions, Schools) { + val region1 = Region.new { + name = "United Kingdom" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + School.find { + Schools.id eq school1.id + }.first().load(School::region) + + assertNotNull(School.testCache(school1.id)) + assertEquals(region1, Region.testCache(School.testCache(school1.id)!!.readValues[Schools.region])) + } + } + } + + @Test + fun preloadOptionalReferencesOnASizedIterable() { + withTables(Regions, Schools) { + val region1 = Region.new { + name = "United Kingdom" + } + + val region2 = Region.new { + name = "England" + } + + val school1 = School.new { + name = "Eton" + region set region1 + secondaryRegion set region2 + }.apply { + // otherwise Oracle provides school1.id = 0 to testCache(), which returns null + if (currentDialectTest is OracleDialect) flush() + } + + val school2 = School.new { + name = "Harrow" + region set region1 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + School.all().with(School::region, School::secondaryRegion) + assertNotNull(School.testCache(school1.id)) + assertNotNull(School.testCache(school2.id)) + + assertEquals(region1, Region.testCache(School.testCache(school1.id)!!.readValues[Schools.region])) + assertEquals(region2, Region.testCache(School.testCache(school1.id)!!.readValues[Schools.secondaryRegion]!!)) + assertEquals(null, School.testCache(school2.id)!!.readValues[Schools.secondaryRegion]) + } + } + } + + @Test + fun preloadOptionalReferencesOnAnEntity() { + withTables(Regions, Schools) { + val region1 = Region.new { + name = "United Kingdom" + } + val region2 = Region.new { + name = "England" + } + + val school1 = School.new { + name = "Eton" + region set region1 + secondaryRegion set region2 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + val school2 = School.find { + Schools.id eq school1.id + }.first().load(School::secondaryRegion) + + assertEquals(null, Region.testCache(school2.readValues[Schools.region])) + assertEquals(region2, Region.testCache(school2.readValues[Schools.secondaryRegion]!!)) + } + } + } + + @Test + fun preloadReferrersOnASizedIterable() { + withTables(Regions, Schools, Students) { + val region1 = Region.new { + name = "United Kingdom" + } + + val region2 = Region.new { + name = "England" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val school2 = School.new { + name = "Harrow" + region set region1 + } + + val school3 = School.new { + name = "Winchester" + region set region2 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "Jack Smith" + school set school2 + } + + val student3 = Student.new { + name = "Henry Smith" + school set school3 + } + + val student4 = Student.new { + name = "Peter Smith" + school set school3 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + val cache = TransactionManager.current().entityCache + + School.all().with(School::students) + + assertEqualCollections(cache.getReferrers(school1.id, Students.school)?.toList().orEmpty(), student1) + assertEqualCollections(cache.getReferrers(school2.id, Students.school)?.toList().orEmpty(), student2) + assertEqualCollections(cache.getReferrers(school3.id, Students.school)?.toList().orEmpty(), student3, student4) + } + } + } + + @Test + fun preloadReferrersOnAnEntity() { + withTables(Regions, Schools, Students) { + val region1 = Region.new { + name = "United Kingdom" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "Jack Smith" + school set school1 + } + + val student3 = Student.new { + name = "Henry Smith" + school set school1 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + val cache = TransactionManager.current().entityCache + + School.find { Schools.id eq school1.id }.first().load(School::students) + + assertEqualCollections(cache.getReferrers(school1.id, Students.school)?.toList().orEmpty(), student1, student2, student3) + } + } + } + + @Test + fun preloadOptionalReferrersOnASizedIterable() { + withTables(Regions, Schools, Students, Detentions) { + val region1 = Region.new { + name = "United Kingdom" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "Jack Smith" + school set school1 + } + + val detention1 = Detention.new { + reason = "Poor Behaviour" + student set student1 + } + + val detention2 = Detention.new { + reason = "Poor Behaviour" + student set student1 + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + School.all().with(School::students, Student::detentions) + val cache = TransactionManager.current().entityCache + + School.all().with(School::students, Student::detentions) + + assertEqualCollections(cache.getReferrers(school1.id, Students.school)?.toList().orEmpty(), student1, student2) + assertEqualCollections(cache.getReferrers(student1.id, Detentions.student)?.toList().orEmpty(), detention1, detention2) + assertEqualCollections(cache.getReferrers(student2.id, Detentions.student)?.toList().orEmpty(), emptyList()) + } + } + } + + @Test + fun preloadInnerTableLinkOnASizedIterable() { + withTables(Regions, Schools, Holidays, SchoolHolidays) { + val now = System.currentTimeMillis() + val now10 = now + 10 + + val region1 = Region.new { + name = "United Kingdom" + } + + val region2 = Region.new { + name = "England" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val school2 = School.new { + name = "Harrow" + region set region1 + } + + val school3 = School.new { + name = "Winchester" + region set region2 + } + + val holiday1 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + val holiday2 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + val holiday3 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + school1.holidays set listOf(holiday1, holiday2) + school2.holidays set listOf(holiday3) + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + School.all().with(School::holidays) + val cache = TransactionManager.current().entityCache + + assertEqualCollections(cache.getReferrers(school1.id, SchoolHolidays.school)?.toList().orEmpty(), holiday1, holiday2) + assertEqualCollections(cache.getReferrers(school2.id, SchoolHolidays.school)?.toList().orEmpty(), holiday3) + assertEqualCollections(cache.getReferrers(school3.id, SchoolHolidays.school)?.toList().orEmpty(), emptyList()) + } + } + } + + @Test + fun preloadInnerTableLinkOnAnEntity() { + withTables(Regions, Schools, Holidays, SchoolHolidays) { + val now = System.currentTimeMillis() + val now10 = now + 10 + + val region1 = Region.new { + name = "United Kingdom" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val holiday1 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + val holiday2 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + val holiday3 = Holiday.new { + holidayStart = now + holidayEnd = now10 + } + + SchoolHolidays.insert { + it[school] = school1.id + it[holiday] = holiday1.id + } + + SchoolHolidays.insert { + it[school] = school1.id + it[holiday] = holiday2.id + } + + SchoolHolidays.insert { + it[school] = school1.id + it[holiday] = holiday3.id + } + + commit() + + School.find { + Schools.id eq school1.id + }.first().load(School::holidays) + + val cache = TransactionManager.current().entityCache + + assertEquals(true, cache.getReferrers(school1.id, SchoolHolidays.school)?.toList()?.contains(holiday1)) + assertEquals(true, cache.getReferrers(school1.id, SchoolHolidays.school)?.toList()?.contains(holiday2)) + assertEquals(true, cache.getReferrers(school1.id, SchoolHolidays.school)?.toList()?.contains(holiday3)) + } + } + + @Test + fun preloadRelationAtDepth() { + withTables(Regions, Schools, Holidays, SchoolHolidays, Students, Notes) { + val region1 = Region.new { + name = "United Kingdom" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "Jack Smith" + school set school1 + } + + val note1 = Note.new { + text = "Note text" + student set student1 + } + + val note2 = Note.new { + text = "Note text" + student set student2 + } + + School.all().with(School::students, Student::notes) + + val cache = TransactionManager.current().entityCache + + assertEquals(true, cache.getReferrers(school1.id, Students.school)?.toList()?.contains(student1)) + assertEquals(true, cache.getReferrers(school1.id, Students.school)?.toList()?.contains(student2)) + assertEquals(note1, cache.getReferrers(student1.id, Notes.student)?.first()) + assertEquals(note2, cache.getReferrers(student2.id, Notes.student)?.first()) + } + } + + @Test + fun preloadBackReferrenceOnASizedIterable() { + withTables(Regions, Schools, Students, StudentBios) { + val region1 = Region.new { + name = "United States" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "John Smith" + school set school1 + } + + val bio1 = StudentBio.new { + student set student1 + dateOfBirth = "01/01/2000" + } + + val bio2 = StudentBio.new { + student set student2 + dateOfBirth = "01/01/2002" + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + Student.all().with(Student::bio) + val cache = TransactionManager.current().entityCache + + assertEqualCollections(cache.getReferrers(student1.id, StudentBios.student)?.toList().orEmpty(), bio1) + assertEqualCollections(cache.getReferrers(student2.id, StudentBios.student)?.toList().orEmpty(), bio2) + } + } + } + + @Test + fun preloadBackReferrenceOnAnEntity() { + withTables(Regions, Schools, Students, StudentBios) { + val region1 = Region.new { + name = "United States" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "John Smith" + school set school1 + } + + val bio1 = StudentBio.new { + student set student1 + dateOfBirth = "01/01/2000" + } + + val bio2 = StudentBio.new { + student set student2 + dateOfBirth = "01/01/2002" + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + Student.all().first().load(Student::bio) + val cache = TransactionManager.current().entityCache + + assertEqualCollections(cache.getReferrers(student1.id, StudentBios.student)?.toList().orEmpty(), bio1) + } + } + } + + @Test + fun `test reference cache doesn't fully invalidated on set entity reference`() { + withTables(Regions, Schools, Students, StudentBios) { + val region1 = Region.new { + name = "United States" + } + + val school1 = School.new { + name = "Eton" + region set region1 + } + + val student1 = Student.new { + name = "James Smith" + school set school1 + } + + val student2 = Student.new { + name = "John Smith" + school set school1 + } + + val bio1 = StudentBio.new { + student set student1 + dateOfBirth = "01/01/2000" + } + + assertEquals(bio1, student1.bio()) + assertEquals(bio1.student(), student1) + } + } + + @Test + fun `test nested entity initialization`() { + withTables(Posts, Categories, Boards) { + val parent1 = Post.new { + board set Board.new { + name = "Parent Board" + } + category set Category.new { + title = "Parent Category" + } + } + + val category1 = parent1.category() + + val post = Post.new { + parent set parent1 + + category set Category.new { + title = "Child Category" + } + + // TODO before everything was inside `new()`, but now it requires suspend context + + optCategory set category1 + } + + assertEquals("Parent Board", post.parent()?.board()?.name) + assertEquals("Parent Category", post.parent()?.category()?.title) + assertEquals("Parent Category", post.optCategory()?.title) + assertEquals("Child Category", post.category()?.title) + } + } + + @Test + fun testExplicitEntityConstructor() { + var createBoardCalled = false + fun createBoard(id: EntityID): Board { + createBoardCalled = true + return Board(id) + } + + val boardEntityClass = object : IntR2dbcEntityClass(Boards, entityCtor = ::createBoard) {} + + withTables(Boards) { + val board = boardEntityClass.new { + name = "Test Board" + } + + assertEquals("Test Board", board.name) + assertTrue( + createBoardCalled + ) + } + } + + object RequestsTable : IdTable() { + val requestId: Column = varchar("requestId", 256) + override val primaryKey = PrimaryKey(requestId) + override val id: Column> = requestId.entityId() + } + + class Request(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(RequestsTable) + + var requestId by RequestsTable.requestId + } + + @Test + fun testSelectFromStringIdTableWithPrimaryKeyByColumn() { + withTables(RequestsTable) { + Request.new { + requestId = "123" + } + + val count = Request.all().count() + assertEquals(1, count) + } + } + + object CreditCards : IntIdTable("CreditCards") { + val number = varchar("number", 16) + val spendingLimit = ulong("spendingLimit").databaseGenerated() + } + + class CreditCard(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(CreditCards) + + var number by CreditCards.number + var spendingLimit by CreditCards.spendingLimit + } + + @Test + fun testDatabaseGeneratedValues() { + withTables(CreditCards) { testDb -> + when (testDb) { + TestDB.POSTGRESQL -> { + // The value can also be set using a SQL trigger + exec( + """ + CREATE OR REPLACE FUNCTION set_spending_limit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS + $$ + BEGIN + NEW."spendingLimit" := 10000; + RETURN NEW; + END; + $$; + """.trimIndent() + ) + exec( + """ + CREATE TRIGGER set_spending_limit + BEFORE INSERT + ON CreditCards + FOR EACH ROW + EXECUTE PROCEDURE set_spending_limit(); + """.trimIndent() + ) + } + else -> { + // This table is only used to get the statement that adds the DEFAULT value, and use it with exec + val creditCards2 = object : IntIdTable("CreditCards") { + val spendingLimit = ulong("spendingLimit").default(10000uL) + } + val missingStatements = SchemaUtils.addMissingColumnsStatements(creditCards2) + missingStatements.forEach { + exec(it) + } + } + } + + val creditCardId = CreditCards.insertAndGetId { + it[number] = "0000111122223333" + }.value + assertEquals( + 10000uL, + CreditCards.selectAll().where { CreditCards.id eq creditCardId }.single()[CreditCards.spendingLimit] + ) + + val creditCard = CreditCard.new { + number = "0000111122223333" + } + + // TODO Unlike JDBC, R2DBC's Column.getValue cannot synchronously flush the entity from a + // non-suspend property accessor (Kotlin operators can't be `suspend`). For columns + // whose value is set by the database (DEFAULT clause / trigger), R2DBC's + // `flushInserts` does not necessarily get those values back through the INSERT + // result row, so we call `refresh(flush = true)` — this flushes the pending insert + // and then re-SELECTs the row to populate `_readValues` with all columns. + creditCard.refresh(flush = true) + + assertEquals(10000uL, creditCard.spendingLimit) + } + } + + @Test + fun testEntityIdParam() { + withTables(CreditCards) { + val newCard = CreditCard.new { + number = "0000111122223333" + spendingLimit = 10000uL + } + // It's also needed because of sync `new()` + flushCache() + + val conditionalId = Case() + .When(CreditCards.spendingLimit less 500uL, CreditCards.id) + .Else(idParam(newCard.id, CreditCards.id)) + assertEquals(newCard.id, CreditCards.select(conditionalId).single()[conditionalId]) + assertEquals( + 10000uL, + CreditCards.select(CreditCards.spendingLimit) + .where { CreditCards.id eq idParam(newCard.id, CreditCards.id) } + .single()[CreditCards.spendingLimit] + ) + } + } + + object Countries : IdTable("Countries") { + override val id = varchar("id", 3).uniqueIndex().entityId() + var name = text("name") + } + + class Country(id: EntityID) : R2dbcEntity(id) { + var name by Countries.name + val dishes by Dish referencedOnSuspend Dishes.country + + companion object : R2dbcEntityClass(Countries) + } + + object Dishes : IntIdTable("Dishes") { + var name = text("name") + val country = reference("country_id", Countries) + } + + class Dish(id: EntityID) : IntR2dbcEntity(id) { + var name by Dishes.name + val country by Country referencedOnSuspend Dishes.country + + companion object : IntR2dbcEntityClass(Dishes) + } + + @Test + fun testEagerLoadingWithStringParentId() { + withTables(Countries, Dishes, configure = { keepLoadedReferencesOutOfTransaction = true }) { + val lebanonId = Countries.insertAndGetId { + it[id] = "LB" + it[name] = "Lebanon" + } + val lebanon = Country.findById(lebanonId)!! + + Dish.new { + name = "Kebbeh" + country set lebanon + } + + Dish.new { + name = "Mjaddara" + country set lebanon + } + + Dish.new { + name = "Fatteh" + country set lebanon + } + + debug = true + + Country.all().with(Country::dishes) + + statementStats + .filterKeys { it.startsWith("SELECT ") } + .forEach { (_, stats) -> + val (count, _) = stats + assertEquals(1, count) + } + + debug = false + } + } + + object Customers : IntIdTable("Customers") { + val emailAddress = varchar("emailAddress", 30).uniqueIndex() + val fullName = text("fullName") + } + + class Customer(id: EntityID) : IntR2dbcEntity(id) { + var emailAddress by Customers.emailAddress + var name by Customers.fullName + + val orders by Order referrersOnSuspend Orders.customer + + companion object : IntR2dbcEntityClass(Customers) + } + + object Orders : IntIdTable("Orders") { + var orderName = text("orderName") + val customer = reference("customer", Customers.emailAddress) + } + + class Order(id: EntityID) : IntR2dbcEntity(id) { + var name by Orders.orderName + val customer by Customer referencedOnSuspend Orders.customer + + companion object : IntR2dbcEntityClass(Orders) + } + + @Test + fun testEagerLoadingWithReferenceDifferentFromParentId() { + withTables(Customers, Orders, configure = { keepLoadedReferencesOutOfTransaction = true }) { + val customer1 = Customer.new { + emailAddress = "customer1@testing.com" + name = "Customer1" + } + + val order1 = Order.new { + name = "Order1" + customer set customer1 + } + + val order2 = Order.new { + name = "Order2" + customer set customer1 + } + + Customer.all().with(Customer::orders) + + val cache = this.entityCache + + assertEquals(true, cache.getReferrers(customer1.id, Orders.customer)?.toList()?.contains(order1)) + assertEquals(true, cache.getReferrers(customer1.id, Orders.customer)?.toList()?.contains(order2)) + } + } + + object TestTable : IntIdTable("TestTable") { + val value = integer("value") + } + + class TestEntityA(id: EntityID) : IntR2dbcEntity(id) { + var value by TestTable.value + + companion object : IntR2dbcEntityClass(TestTable) + } + + class TestEntityB(id: EntityID) : IntR2dbcEntity(id) { + var value by TestTable.value + + companion object : IntR2dbcEntityClass(TestTable) + } + + @Test + fun testDifferentEntitiesMappedToTheSameTable() { + withTables(TestTable) { + val entityA = TestEntityA.new { + value = 1 + } + val entityB = TestEntityB.new { + value = 2 + } + + flushCache() + + entityA.value = 3 + entityB.value = 4 + + flushCache() + } + } +} + +/** + * This method is used just to keep tests similar to jdbc alternatives + * (otherwise it's necessary to replace `forEach` with `collect` in all the tests) + */ +internal suspend fun SizedIterable.forEach(collector: FlowCollector) = + collect(collector) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt index 5ff4540f19..0dfff06cb0 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt @@ -1,16 +1,19 @@ package org.jetbrains.exposed.r2dbc.dao import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException +import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcInnerTableLink import org.jetbrains.exposed.v1.core.AutoIncColumnType import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.EntityIDColumnType import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.dao.id.CompositeID import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.deleteWhere import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import org.jetbrains.exposed.v1.r2dbc.update import kotlin.collections.get @@ -54,6 +57,7 @@ open class R2dbcEntity(val id: EntityID) { } operator fun Column.setValue(entity: R2dbcEntity, desc: KProperty<*>, value: T) { + klass.invalidateEntityInCache(entity) val currentValue = _readValues?.getOrNull(this) if (writeValues.containsKey(this as Column) || currentValue != value) { val entityCache = TransactionManager.current().entityCache @@ -132,9 +136,11 @@ open class R2dbcEntity(val id: EntityID) { @Suppress("ForbiddenComment") // TODO: Implement entity change tracking when subscriptions are implemented - table.update({ table.id eq id }) { - for ((c, v) in _writeValues) { - it[c] = v + executeAsPartOfEntityLifecycle { + table.update({ table.id eq id }) { + for ((c, v) in _writeValues) { + it[c] = v + } } } // TODO: Implement alertSubscribers when subscriptions are implemented @@ -151,6 +157,21 @@ open class R2dbcEntity(val id: EntityID) { return false } + open suspend fun delete() { + val table = klass.table + val entityId = this.id + // TODO add register change like in JDBCHello. + // TODO should we insert before and then remove + // (extra requests, but probablyt correctness could be better) + if (!isNewEntity()) { + executeAsPartOfEntityLifecycle { + table.deleteWhere { table.id eq entityId } + } + } + + klass.removeFromCache(this) + } + internal fun hasInReferenceCache(ref: Column<*>): Boolean { return ref in referenceCache } @@ -183,6 +204,40 @@ open class R2dbcEntity(val id: EntityID) { _readValues = reloaded.readValues db = transaction.db } + + /** + * Registers an intermediate [table] as a many-to-many link between this entity's table and + * the target [R2dbcEntityClass]. The source and target columns are inferred from the + * intermediate table's foreign keys. + * + * Counterpart of JDBC's `via`. Named `viaSuspend` to match the rest of the R2DBC DAO API. + */ + infix fun > R2dbcEntityClass.viaSuspend( + table: Table + ): R2dbcInnerTableLink, TID, Target> = + R2dbcInnerTableLink( + table = table, + sourceTable = this@R2dbcEntity.id.table, + target = this@viaSuspend + ) + + /** + * Registers an intermediate table as a many-to-many link with explicitly specified + * [sourceColumn] and [targetColumn] — use this when the intermediate table has multiple + * references into the same entity's table and the defaults cannot be inferred. + */ + // TODO similar to jdbc, but not covered with tests yet + fun > R2dbcEntityClass.viaSuspend( + sourceColumn: Column>, + targetColumn: Column> + ): R2dbcInnerTableLink, TID, Target> = + R2dbcInnerTableLink( + table = sourceColumn.table, + sourceTable = this@R2dbcEntity.id.table, + target = this@viaSuspend, + _sourceColumn = sourceColumn, + _targetColumn = targetColumn + ) } class R2dbcEntityBatchUpdate { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt index 71304a44be..9370d77ee8 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt @@ -4,9 +4,12 @@ import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.transactions.transactionScope +import org.jetbrains.exposed.v1.r2dbc.LazySizedCollection import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.SizedIterable import org.jetbrains.exposed.v1.r2dbc.insert +import java.util.LinkedHashMap import java.util.concurrent.ConcurrentHashMap val R2dbcTransaction.entityCache: R2dbcEntityCache by transactionScope { @@ -25,22 +28,50 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { internal val referrers = ConcurrentHashMap, MutableMap, Any>>() - var maxEntitiesToStore = transaction.db.config.maxEntitiesToStoreInCachePerEntity - fun > find(entityClass: R2dbcEntityClass, id: EntityID): T? { - val map = data[entityClass.table] ?: return null - return map[id.value] as T? - ?: inserts[entityClass.table]?.firstOrNull { it.id == id } as? T - ?: initializingEntities.firstOrNull { it.klass == entityClass && it.id == id } as? T + // `id.value` can not be used, because it can't insert the entity in the case it's null + if (id._value == null) { + return inserts[entityClass.table]?.firstOrNull { it.id == id } as? T + ?: initializingEntities.firstOrNull { it.klass == entityClass && it.id == id } as? T + } + + return getMap(entityClass)[id.value] as T? + } + + private fun getMap(f: R2dbcEntityClass<*, *>): MutableMap> = getMap(f.table) + + private fun getMap(table: IdTable<*>): MutableMap> = data.getOrPut(table) { + LimitedHashMap() + } + + private inner class LimitedHashMap : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > maxEntitiesToStore + } } + var maxEntitiesToStore = transaction.db.config.maxEntitiesToStoreInCachePerEntity + set(value) { + val diff = value - field + field = value + if (diff < 0) { + data.values.forEach { it.trimToFirst(value) } + } + } + fun store(entity: R2dbcEntity) { val map = data.getOrPut(entity.klass.table) { ConcurrentHashMap() } map[entity.id.value] = entity } fun remove(table: IdTable, entity: R2dbcEntity) { - data[table]?.remove(entity.id.value) + // Same as in find(), 'entity.id.value' can not be used directly, because + // because the entity could not be fetched in this moment. Another option is to + // make 'remove()' suspend + if (entity.id._value != null) { + data[table]?.remove(entity.id._value) + } + inserts[table]?.remove(entity) } fun scheduleUpdate(klass: R2dbcEntityClass>, entity: R2dbcEntity) { @@ -54,12 +85,19 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { private val initializingEntities = LinkedIdentityHashSet>() - val pendingInitializationLambdas = ConcurrentHashMap, MutableList) -> Unit>>() - fun isEntityInInitializationState(entity: R2dbcEntity): Boolean { return initializingEntities.contains(entity) } + fun isScheduledForInsert(entity: R2dbcEntity): Boolean { + return inserts[entity.klass.table]?.contains(entity) ?: false + } + + fun isStoredInData(entity: R2dbcEntity): Boolean { + val value = entity.id._value ?: return false + return data[entity.klass.table]?.get(value) === entity + } + fun addNotInitializedEntityToQueue(entity: R2dbcEntity) { require(initializingEntities.add(entity)) { "Entity ${entity::class.simpleName} already in initialization process" } } @@ -75,13 +113,19 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { inserts.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) } - suspend fun getOrPutReferrers( + suspend fun > getOrPutReferrers( column: Column<*>, sourceId: EntityID<*>, - refs: suspend () -> Any - ): Any { + refs: suspend () -> SizedIterable<@UnsafeVariance R> + ): SizedIterable { val columnReferrers = referrers.getOrPut(column) { ConcurrentHashMap() } - return columnReferrers.getOrPut(sourceId) { refs() } + @Suppress("UNCHECKED_CAST") + return columnReferrers.getOrPut(sourceId) { LazySizedCollection(refs()) } as SizedIterable + } + + fun > getReferrers(sourceId: EntityID<*>, key: Column<*>): SizedIterable? { + @Suppress("UNCHECKED_CAST") + return referrers[key]?.get(sourceId) as? SizedIterable } fun removeReferrer(column: Column<*>, entityId: EntityID<*>) { @@ -154,22 +198,33 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { val entitiesToInsert = inserts.remove(table)?.toList().orEmpty() if (entitiesToInsert.isEmpty()) return + // We have to handle self references in r2dbc here comparing to jdbc, because + // in jdbc the entity that gets self reference would be inserted in the moment + // of setting that self reference + val entitiesWithSelfRefs = mutableListOf>() + for (entity in entitiesToInsert) { val entityId = entity.id - val writeValues = entity.writeValues.toMap() + val allWriteValues = entity.writeValues.toMap() + + // Separate self-references to the same table whose target id is not yet generated. + // Such values cannot be included in the INSERT because the referenced id does not + // exist yet. They are applied as a follow-up UPDATE once the id has been generated. + val selfRefs = allWriteValues.filter { (key, value) -> + key.referee == table.id && value is EntityID<*> && value._value == null + } + val insertValues = allWriteValues - selfRefs.keys val insertStatement = table.insert { - for ((column, value) in writeValues) { - @Suppress("UNCHECKED_CAST") - it[column as Column] = value + for ((column, value) in insertValues) { + it[column] = value } } val resultRow = insertStatement.resultedValues?.firstOrNull() if (resultRow != null) { - @Suppress("UNCHECKED_CAST") - val generatedId = resultRow[table.id] as EntityID + val generatedId = resultRow[table.id] if (entityId._value == null) { entityId._value = generatedId.value } @@ -177,7 +232,47 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { } entity.writeValues.clear() + + // Restore self-reference values to writeValues for a post-insert update. + // The referenced id is now populated, so these can be safely serialized. + if (selfRefs.isNotEmpty()) { + for ((column, value) in selfRefs) { + entity.writeValues[column] = value + } + entitiesWithSelfRefs.add(entity) + } + store(entity) } + + for (entity in entitiesWithSelfRefs) { + entity.flush() + } + } +} + +suspend fun R2dbcTransaction.flushCache(): List> { + with(entityCache) { + val newEntities = inserts.flatMap { it.value } + flush() + return newEntities + } +} + +/** + * Drops entries from the front of this map until its [size] is at most [maxSize]. + * + * Extracted from `R2dbcEntityCache.maxEntitiesToStore`'s setter so the setter reads as intent + * ("trim each per-table map to the new max") rather than carrying the iterator mechanics inline. + * Relies on insertion-order iteration of the per-table cache (see [R2dbcEntityCache.LimitedHashMap]) + * to evict the oldest entries first. + */ +private fun MutableMap.trimToFirst(maxSize: Int) { + val sizeExceed = size - maxSize + if (sizeExceed <= 0) return + val iterator = iterator() + repeat(sizeExceed) { + iterator.next() + iterator.remove() } } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt index 17464cd52c..ec5316c840 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -1,6 +1,7 @@ package org.jetbrains.exposed.r2dbc.dao import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.singleOrNull import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnSet @@ -10,6 +11,9 @@ import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.Query +import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.mapLazy +import org.jetbrains.exposed.v1.r2dbc.select import org.jetbrains.exposed.v1.r2dbc.selectAll import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import kotlin.reflect.KFunction @@ -74,26 +78,95 @@ abstract class R2dbcEntityClass>( val cached = testCache(id) if (cached != null) return cached - val row = find { table.id eq id }.firstOrNull() - return row?.let { wrapRow(it) } + return find { table.id eq id }.firstOrNull() + } + + suspend fun findByIdAndUpdate(id: ID, block: (it: T) -> Unit): T? { + val result = find(table.id eq R2dbcDaoEntityID(id, table)).forUpdate().firstOrNull() ?: return null + block(result) + return result + } + + suspend fun findSingleByAndUpdate(op: Op, block: (it: T) -> Unit): T? { + val result = find(op).forUpdate().singleOrNull() ?: return null + block(result) + return result } fun testCache(id: EntityID): T? = warmCache().find(this, id) - open fun all(): Query { - warmCache() - return table.selectAll() + suspend fun reload(entity: R2dbcEntity, flush: Boolean = false): T? { + if (flush) { + if (entity.isNewEntity()) { + TransactionManager.current().entityCache.flushInserts(table) + } else { + entity.flush() + } + } + removeFromCache(entity) + return if (entity.id._value != null) findById(entity.id) else null } - fun find(op: Op): Query { + // It actually doesn't do 'invalidation', because it's suspend operation, but this method + // is used on setting value to the entity + internal open fun invalidateEntityInCache(o: R2dbcEntity) { + val sameDatabase = TransactionManager.current().db == o.db + if (!sameDatabase) return + + val cache = warmCache() + + // TODO could I reverse this condition in the way to check which state of entity should + // throw error, rather than which should not. + if (cache.isEntityInInitializationState(o)) return + if (cache.isScheduledForInsert(o)) return + if (cache.isStoredInData(o)) return + + // Not in any tracked state. Either the entity has been deleted in the current + // transaction, or it was loaded in a different transaction and has not been + // `attach`-ed here. Both are user bugs — fail loudly. + // + // R2DBC cannot mirror JDBC's `get(o.id)` "verify-and-adopt" shortcut because + // Column.setValue is not a suspend operator and cannot query the database. + throw R2dbcEntityNotFoundException(o.id, this) + } + + /** + * This method is used in r2dbc now for the case when one entity is + * reused between transactions. In jdbc it's not needed, because there on 'setValue' + * we could attach it implicitly, but in r2dbc 'setValue' on entity is non-suspendable, + * so we can't do that, and user must do that explicitly. + * + * I still don't like reusing entities between transactions as a pattern, probably it should + * be deprecated, but it sounds like a bad idea in terms of API changes (even on major versions), + * because it could be used by many users. + */ + suspend fun attach(entity: R2dbcEntity) { + val cache = warmCache() + if (cache.find(this, entity.id) != null) return + + // Verify the row still exists — also stores a fresh instance in the cache as a side effect. + findById(entity.id) ?: throw R2dbcEntityNotFoundException(entity.id, this) + + // Overwrite the freshly-loaded instance with the caller's reference so that subsequent + // writes on `entity` flow through the same cache entry (mirrors JDBC's `warmCache().store(o)`). + cache.store(entity) + } + + open fun all(): SizedIterable = wrapRows(table.selectAll().notForUpdate()) + + fun find(op: Op): SizedIterable { warmCache() - return searchQuery(op) + return wrapRows(searchQuery(op)) } - fun find(op: () -> Op): Query = find(op()) + fun find(op: () -> Op): SizedIterable = find(op()) open fun searchQuery(op: Op): Query = - dependsOnTables.selectAll().where { op }.notForUpdate() + dependsOnTables.select(dependsOnColumns).where { op }.notForUpdate() + + fun wrapRows(rows: SizedIterable): SizedIterable = rows mapLazy { + wrapRow(it) + } @Suppress("MemberVisibilityCanBePrivate") fun wrapRow(row: ResultRow): T { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt index 44633f9525..3b5c9084f7 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt @@ -5,10 +5,23 @@ import org.jetbrains.exposed.v1.core.Key import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.statements.* import org.jetbrains.exposed.v1.core.targetTables +import org.jetbrains.exposed.v1.core.transactions.transactionScope import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction import org.jetbrains.exposed.v1.r2dbc.statements.GlobalSuspendStatementInterceptor import org.jetbrains.exposed.v1.r2dbc.statements.api.R2dbcPreparedStatementApi +private var isExecutedWithinEntityLifecycle by transactionScope { false } + +internal suspend fun executeAsPartOfEntityLifecycle(body: suspend () -> T): T { + val currentExecutionState = isExecutedWithinEntityLifecycle + return try { + isExecutedWithinEntityLifecycle = true + body() + } finally { + isExecutedWithinEntityLifecycle = currentExecutionState + } +} + class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { override fun keepUserDataInTransactionStoreOnCommit(userData: Map, Any?>): Map, Any?> { @@ -31,11 +44,21 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { is DeleteStatement -> { transaction.flushCache() transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + if (!isExecutedWithinEntityLifecycle) { + statement.targetsSet.targetTables().filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } } is UpsertStatement<*>, is BatchUpsertStatement -> { transaction.flushCache() transaction.entityCache.removeTablesReferrers(statement.targets.filterIsInstance>()) + if (!isExecutedWithinEntityLifecycle) { + statement.targets.filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } } is InsertStatement<*> -> { @@ -48,6 +71,11 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { is UpdateStatement -> { transaction.flushCache() transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + if (!isExecutedWithinEntityLifecycle) { + statement.targetsSet.targetTables().filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } } else -> { @@ -102,11 +130,3 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { entityCache.flush(tables) } } - -// Extension functions for R2dbcTransaction -suspend fun R2dbcTransaction.flushCache(): List> { - entityCache.flush() - @Suppress("ForbiddenComment") - // TODO: Return list of created entities when entity change tracking is implemented - return emptyList() -} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt index 7d11ed01ed..c1af100409 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/exceptions/R2dbcEntityNotFoundException.kt @@ -4,4 +4,4 @@ import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.v1.core.dao.id.EntityID class R2dbcEntityNotFoundException(val id: EntityID<*>, val entity: R2dbcEntityClass<*, *>) : - Exception("Entity ${entity.klass.simpleName}, id=$id not found in the database") + Exception("Entity ${entity.klass.simpleName}, id=${id._value} not found in the database") diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt index c8ae0fe9cc..c47a442d0f 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt @@ -1,10 +1,25 @@ package org.jetbrains.exposed.r2dbc.dao.relationships +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import kotlin.reflect.KProperty +/** + * Ensures the entity has a populated id before its back-reference is queried. JDBC handles + * this implicitly via `DaoEntityID.invokeOnNoValue` from `thisRef.id.value`; R2DBC has to do + * it as an explicit suspending step because R2dbcDaoEntityID can't trigger `flushInserts` + * (which is `suspend`) from a non-suspend getter. + */ +private suspend fun R2dbcEntity<*>.ensureIdFlushed() { + if (id._value != null) return + TransactionManager.current().entityCache.flush() +} + class R2dbcBackReference, ChildID : Any, in Child : R2dbcEntity, REF>( reference: Column, factory: R2dbcEntityClass @@ -16,13 +31,11 @@ class R2dbcBackReference, Chi ) operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent { - thisRef.id.value - val referrersLambda = delegate.getValue(thisRef, property) return suspend { - val referrers = referrersLambda() - referrers.single() + thisRef.ensureIdFlushed() + referrersLambda().single() } } } @@ -38,13 +51,11 @@ class R2dbcOptionalBackReference): suspend () -> Parent? { - thisRef.id.value - val referrersLambda = delegate.getValue(thisRef, property) return suspend { - val referrers = referrersLambda() - referrers.singleOrNull() + thisRef.ensureIdFlushed() + referrersLambda().singleOrNull() } } } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt new file mode 100644 index 0000000000..a68395ed46 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt @@ -0,0 +1,266 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.EntityIDColumnType +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.r2dbc.LazySizedIterable +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.select +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +/** + * Eager-loads the specified [relations] for all entities in this [SizedIterable]. Mirrors JDBC's + * `SizedIterable.with` — each direct relation is bulk-loaded via a single query instead of being + * fetched lazily one entity at a time. + * + * Returns this [SizedIterable] to allow chaining; the loaded list is also pinned onto any + * [LazySizedIterable] so subsequent iterations do not re-query the database. + */ +suspend fun , REF : R2dbcEntity<*>, L : SizedIterable> L.with( + vararg relations: KProperty1 +): L { + toList().apply { + @Suppress("UNCHECKED_CAST") + (this@with as? LazySizedIterable)?.loadedResult = this + if (any { it.isNewEntity() }) { + TransactionManager.current().flushCache() + } + preloadRelations(*relations) + } + return this +} + +/** + * Eager-loads the specified [relations] for this entity. Mirrors JDBC's `Entity.load`. + */ +suspend fun > SRC.load( + vararg relations: KProperty1, Any?> +): SRC = apply { + SizedCollection(listOf(this)).with(*relations) +} + +@Suppress("UNCHECKED_CAST", "ForbiddenComment") +private suspend fun List>.preloadRelations( + vararg relations: KProperty1, Any?>, + nodesVisited: MutableSet> = mutableSetOf() +) { + val first = firstOrNull() ?: return + if (!nodesVisited.add(first.klass)) return + + val directRelations = filterRelationsForEntity(first, relations) + val loadedByRelation = mutableListOf>() + + directRelations.forEach { prop -> + val loaded: List> = when (val refObject = getReferenceObjectFromDelegatedProperty(first, prop)) { + is SuspendAccessor<*, *, *> -> + preloadReference(refObject as SuspendAccessor, Any>) + is OptionalSuspendAccessor<*, *, *> -> + preloadOptionalReference(refObject as OptionalSuspendAccessor, Any>) + is R2dbcReferrers<*, *, *, *, *> -> + preloadReferrers(refObject as R2dbcReferrers, Any, R2dbcEntity, Any>) + is R2dbcInnerTableLinkAccessor<*, *, *, *> -> + preloadInnerTableLink(refObject as R2dbcInnerTableLinkAccessor, Any, R2dbcEntity>) + is R2dbcBackReference<*, *, *, *, *> -> + preloadReferrers(refObject.delegate as R2dbcReferrers, Any, R2dbcEntity, Any>) + is R2dbcOptionalBackReference<*, *, *, *, *> -> + preloadReferrers(refObject.delegate as R2dbcReferrers, Any, R2dbcEntity, Any>) + else -> emptyList() + } + loadedByRelation += loaded + } + + // Mirrors JDBC's recursive step in `preloadRelations`. + if (directRelations.isNotEmpty() && relations.size != directRelations.size) { + val remainingRelations = (relations.toList() - directRelations.toSet()).toTypedArray() + loadedByRelation.groupBy { it::class }.forEach { (_, entities) -> + (entities as List>).preloadRelations( + relations = remainingRelations, + nodesVisited = nodesVisited + ) + } + } +} + +/** + * Bulk-loads parents referenced by a non-nullable [SuspendAccessor]-backed property, for every + * entity in the receiver list. Loaded parents are inserted into the entity cache by the factory's + * `find(...)` traversal (via `wrapRow`). + */ +@Suppress("UNCHECKED_CAST") +private suspend fun List>.preloadReference( + accessor: SuspendAccessor, Any> +): List> { + val reference = accessor.reference + val factory = accessor.factory + val refIds = mapNotNull { entity -> entity.lookupRefValue(reference) } + if (refIds.isEmpty()) return emptyList() + + val referee = reference.referee ?: return emptyList() + val condition = buildInListCondition(referee, refIds.distinct()) + return factory.find { condition }.toList() +} + +/** + * Bulk-loads parents referenced by an [OptionalSuspendAccessor]-backed property. + */ +@Suppress("UNCHECKED_CAST") +private suspend fun List>.preloadOptionalReference( + accessor: OptionalSuspendAccessor, Any> +): List> { + val reference = accessor.reference as Column + val factory = accessor.factory + val refIds = mapNotNull { entity -> entity.lookupRefValue(reference) } + if (refIds.isEmpty()) return emptyList() + + val referee = reference.referee ?: return emptyList() + val condition = buildInListCondition(referee, refIds.distinct()) + return factory.find { condition }.toList() +} + +/** + * Bulk-loads child entities for a one-to-many relationship (`R2dbcReferrers`-backed property), + * for every parent in the receiver list. Children are grouped by their reference column value + * and inserted into the entity cache's referrers slot — so subsequent calls to the parent's + * accessor (`parent.children()`) hit the cache without issuing per-parent queries. + * + * Mirrors JDBC's `warmUpReferences` for the simple `EntityIDColumnType` case. + */ +private suspend fun List>.preloadReferrers( + referrers: R2dbcReferrers, Any, R2dbcEntity, Any> +): List> { + val refColumn = referrers.reference + val factory = referrers.factory + + val referee = refColumn.referee ?: return emptyList() + + val parentMappings: List, Any>> = mapNotNull { entity -> + if (entity.id._value == null) return@mapNotNull null + val refereeValue = entity.lookupRefValue(referee) ?: return@mapNotNull null + entity.id to refereeValue + } + if (parentMappings.isEmpty()) return emptyList() + + val cache = TransactionManager.current().entityCache + + val toLoadMappings = parentMappings.filter { (parentId, _) -> + cache.getReferrers>(parentId, refColumn) == null + } + + if (toLoadMappings.isEmpty()) { + // Already cached for every parent — return the union of cached children so + // transitive preloading of remaining relations still has entities to recurse on. + return parentMappings.flatMap { (parentId, _) -> + cache.getReferrers>(parentId, refColumn)?.toList().orEmpty() + } + } + + val refereeValuesToLoad = toLoadMappings.map { it.second }.distinct() + val condition = buildInListCondition(refColumn, refereeValuesToLoad) + val loadedChildren = factory.find { condition }.toList() + + val grouped: Map>> = loadedChildren.groupBy { child -> + @Suppress("UNCHECKED_CAST") + child.lookupRefValue(refColumn as Column) as Any + } + + parentMappings.forEach { (parentId, refereeValue) -> + cache.getOrPutReferrers(refColumn, parentId) { + // NOTE: we deliberately use `SizedCollection(emptyList())` rather than `emptySized()` + // for parents with no children. R2DBC's `EmptySizedIterable.collect` throws + // UnsupportedOperationException, which would propagate when the cached value is + // later read via `.toList()` (e.g. by transitive preload or by the user). + SizedCollection(grouped[refereeValue] ?: emptyList()) + } + } + + return loadedChildren +} + +private suspend fun List>.preloadInnerTableLink( + accessor: R2dbcInnerTableLinkAccessor, Any, R2dbcEntity> +): List> { + val link = accessor.link + val sourceColumn = link.sourceColumn + val target = link.target + + val parentIds = mapNotNull { entity -> entity.id._value?.let { entity.id } } + if (parentIds.isEmpty()) return emptyList() + + val distinctParentIds = parentIds.distinct() + val cache = TransactionManager.current().entityCache + + val toLoad = distinctParentIds.filter { id -> + cache.getReferrers>(id, sourceColumn) == null + } + + if (toLoad.isEmpty()) { + return distinctParentIds.flatMap { id -> + cache.getReferrers>(id, sourceColumn)?.toList().orEmpty() + } + } + + val (columns, entityTables) = link.columnsAndTables + + val rows = entityTables.select(columns).where { sourceColumn inList toLoad } + .toList() + + val pairs: List, R2dbcEntity>> = rows.map { row -> + @Suppress("UNCHECKED_CAST") + val parentId = row[sourceColumn] as EntityID + val targetEntity = target.wrapRow(row) as R2dbcEntity + parentId to targetEntity + } + + val groupedBySourceId: Map, List>> = pairs + .groupBy({ it.first }, { it.second }) + + toLoad.forEach { id -> + cache.getOrPutReferrers(sourceColumn, id) { + SizedCollection(groupedBySourceId[id] ?: emptyList()) + } + } + + return pairs.map { it.second }.distinct() +} + +private fun R2dbcEntity<*>.lookupRefValue(reference: Column<*>): Any? { + @Suppress("UNCHECKED_CAST") + return writeValues[reference as Column] ?: _readValues?.let { row -> row.getOrNull(reference) } +} + +@Suppress("UNCHECKED_CAST") +private fun buildInListCondition(referee: Column<*>, refIds: List): org.jetbrains.exposed.v1.core.Op { + // If the reference column stores a raw value while the referee is an EntityID column (or vice + // versa), normalise the types before building the IN (...) expression. + val baseColumn = referee.takeUnless { + it.columnType is EntityIDColumnType<*> && refIds.first() !is EntityID<*> + } ?: (referee.columnType as EntityIDColumnType).idColumn + return (baseColumn as Column) inList refIds +} + +private fun > filterRelationsForEntity( + entity: SRC, + relations: Array, Any?>> +): Collection> { + val validMembers = entity::class.memberProperties + @Suppress("UNCHECKED_CAST") + return validMembers.filter { it in relations } as Collection> +} + +private fun > getReferenceObjectFromDelegatedProperty( + entity: SRC, + property: KProperty1 +): Any? { + property.isAccessible = true + return property.getDelegate(entity) +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt new file mode 100644 index 0000000000..c5723bf7bd --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt @@ -0,0 +1,130 @@ +package org.jetbrains.exposed.r2dbc.dao.relationships + +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.executeAsPartOfEntityLifecycle +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.batchInsert +import org.jetbrains.exposed.v1.r2dbc.deleteWhere +import org.jetbrains.exposed.v1.r2dbc.emptySized +import org.jetbrains.exposed.v1.r2dbc.select +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KProperty + +@Suppress("UNCHECKED_CAST") +class R2dbcInnerTableLink, ID : Any, Target : R2dbcEntity>( + val table: Table, + sourceTable: IdTable, + val target: R2dbcEntityClass, + _sourceColumn: Column>? = null, + _targetColumn: Column>? = null, +) { + private val orderByExpressions: MutableList, SortOrder>> = mutableListOf() + + init { + _targetColumn?.let { + requireNotNull(_sourceColumn) { "Both source and target columns should be specified" } + require(_targetColumn.referee?.table == target.table) { + "Column $_targetColumn point to wrong table, expected ${target.table.tableName}" + } + require(_targetColumn.table == _sourceColumn.table) { + "Both source and target columns should be from the same table" + } + } + _sourceColumn?.let { + requireNotNull(_targetColumn) { "Both source and target columns should be specified" } + require(_sourceColumn.referee?.table == sourceTable) { + "Column $_sourceColumn point to wrong table, expected ${sourceTable.tableName}" + } + } + } + + val sourceColumn: Column> = _sourceColumn + ?: table.columns.singleOrNull { it.referee == sourceTable.id } as? Column> + ?: error("Table does not reference source") + + val targetColumn: Column> = _targetColumn + ?: table.columns.singleOrNull { it.referee == target.table.id } as? Column> + ?: error("Table does not reference target") + + internal val columnsAndTables by lazy { + val alreadyInJoin = (target.dependsOnTables as? Join)?.alreadyInJoin(table) ?: false + val entityTables = if (alreadyInJoin) { + target.dependsOnTables + } else { + target.dependsOnTables.join(table, JoinType.INNER, target.table.id, targetColumn) + } + val columns = (target.dependsOnColumns + (if (!alreadyInJoin) table.columns else emptyList()) - sourceColumn) + .distinct() + sourceColumn + columns to entityTables + } + + internal fun orderByExpressionsArray(): Array, SortOrder>> = orderByExpressions.toTypedArray() + + operator fun provideDelegate( + thisRef: Source, + property: KProperty<*> + ): R2dbcInnerTableLinkAccessor = R2dbcInnerTableLinkAccessor(this, thisRef) + + infix fun orderBy(order: List, SortOrder>>) = this.also { + orderByExpressions.addAll(order) + } + + infix fun orderBy(order: Pair, SortOrder>) = orderBy(listOf(order)) + + infix fun orderBy(expression: Expression<*>) = orderBy(listOf(expression to SortOrder.ASC)) +} + +@Suppress("UNCHECKED_CAST") +class R2dbcInnerTableLinkAccessor, ID : Any, Target : R2dbcEntity>( + val link: R2dbcInnerTableLink, + val entity: Source +) { + operator fun getValue(thisRef: Source, property: KProperty<*>): R2dbcInnerTableLinkAccessor = this + + suspend operator fun invoke(): SizedIterable { + if (entity.id._value == null && !entity.isNewEntity()) return emptySized() + val transaction = TransactionManager.currentOrNull() + ?: return entity.getReferenceFromCache(link.sourceColumn) + + val (columns, entityTables) = link.columnsAndTables + + val query: suspend () -> SizedIterable = { + @Suppress("SpreadOperator") + link.target.wrapRows( + entityTables.select(columns) + .where { link.sourceColumn eq entity.id } + .orderBy(*link.orderByExpressionsArray()) + ) + } + return transaction.entityCache.getOrPutReferrers(link.sourceColumn, entity.id, query).also { + entity.storeReferenceInCache(link.sourceColumn, it) + } + } + + suspend infix fun set(value: List) { + val tx = TransactionManager.current() + val entityCache = tx.entityCache + entityCache.flush() + val oldValue = invoke().toList() + val existingIds = oldValue.map { it.id }.toSet() + entityCache.removeReferrer(link.sourceColumn, entity.id) + + val targetIds = value.map { it.id } + executeAsPartOfEntityLifecycle { + link.table.deleteWhere { (link.sourceColumn eq entity.id) and (link.targetColumn notInList targetIds) } + link.table.batchInsert( + targetIds.filter { it !in existingIds }, + shouldReturnGeneratedValues = false + ) { targetId -> + this[link.sourceColumn] = entity.id + this[link.targetColumn] = targetId + } + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt index c7a929ee6d..e7b617cc88 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt @@ -1,11 +1,12 @@ package org.jetbrains.exposed.r2dbc.dao.relationships -import kotlinx.coroutines.flow.toList import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.emptySized import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import kotlin.reflect.KProperty @@ -25,13 +26,13 @@ class R2dbcReferrers, ChildID } @Suppress("NestedBlockDepth", "ForbiddenComment") - operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> List { + operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> SizedIterable { // Return a suspend lambda that will load the referrers when invoked return { // Check if entity ID is available if (thisRef.id._value == null) { // TODO should it be error? - emptyList() + emptySized() } else { val transaction = TransactionManager.currentOrNull() @@ -39,9 +40,10 @@ class R2dbcReferrers, ChildID if (transaction == null) { if (thisRef.hasInReferenceCache(reference)) { val cached = thisRef.getReferenceFromCache(reference) + @Suppress("UNCHECKED_CAST") when (cached) { - is List<*> -> cached as List - null -> emptyList() + is SizedIterable<*> -> cached as SizedIterable + null -> emptySized() else -> error("Cached referrer has unexpected type: ${cached::class}") } } else { @@ -57,15 +59,14 @@ class R2dbcReferrers, ChildID } as REF // Build the query for child entities - val query: suspend () -> List = { - val resultRows = factory.find { reference eq refValue }.toList() - resultRows.map { factory.wrapRow(it) } + val query: suspend () -> SizedIterable = { + factory.find { reference eq refValue } } // Execute query with caching if enabled val result = if (cache) { @Suppress("UNCHECKED_CAST") - transaction.entityCache.getOrPutReferrers(reference, thisRef.id, query) as List + (transaction.entityCache.getOrPutReferrers(reference, thisRef.id, query)) } else { query() } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt index cf8d63baaf..f5fad5685e 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt @@ -76,3 +76,17 @@ infix fun , ChildID : Any, Child R2dbcEntityClass.optionalBackReferencedOnSuspend(reference: Column): R2dbcOptionalBackReference { return R2dbcOptionalBackReference(reference, this) } + +/** + * Overload for referencing a non-nullable column as an optional back reference. + * + * The child entity's referencing column is required (non-nullable) but, from the parent + * side, the relationship is still optional: a child row may or may not exist. This mirrors + * JDBC's `optionalBackReferencedOn(Column)` overload. + */ +@Suppress("UNCHECKED_CAST") +@JvmName("optionalBackReferencedOnSuspendNonNullable") +infix fun , ChildID : Any, Child : R2dbcEntity, REF : Any> + R2dbcEntityClass.optionalBackReferencedOnSuspend(reference: Column): R2dbcOptionalBackReference { + return R2dbcOptionalBackReference(reference as Column, this) +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt index e4906ee8d9..db3f6a5699 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt @@ -1,19 +1,21 @@ package org.jetbrains.exposed.r2dbc.dao.relationships +import kotlinx.coroutines.flow.singleOrNull import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import kotlin.collections.get import kotlin.reflect.KProperty class SuspendAccessor, REF : Any>( - private val reference: Column, - private val factory: R2dbcEntityClass, - private val entity: R2dbcEntity<*> + internal val reference: Column, + internal val factory: R2dbcEntityClass, + internal val entity: R2dbcEntity<*> ) { /** * getValue operator - returns this accessor which has invoke() and set operations. @@ -73,16 +75,39 @@ class SuspendAccessor, REF : Any>( } suspend operator fun invoke(): Parent { - @Suppress("ForbiddenComment") - // TODO: Implement reference loading similar to OptionalSuspendAccessor - TODO("Not yet implemented") + if (entity.hasInReferenceCache(reference)) { + return entity.getReferenceFromCache(reference) + } + + // TODO incapsulate this logic inside entity to avoid checking for different fields outside. + @Suppress("UNCHECKED_CAST") + val refValue: REF = (entity.writeValues[reference as Column] as? REF) + ?: (entity._readValues?.let { row -> row[reference] } as? REF) + ?: error("Reference column ${reference.name} has no value for entity ${entity.id}") + + val parentEntity = when { + reference.referee == factory.table.id -> { + @Suppress("UNCHECKED_CAST") + factory.findById(refValue as EntityID) + } + reference.referee?.table == factory.table -> { + @Suppress("UNCHECKED_CAST") + val refereeColumn = reference.referee!! as Column + factory.find { refereeColumn eq refValue }.singleOrNull() + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + } ?: error("Referenced entity not found for column ${reference.name} with value $refValue") + + entity.storeReferenceInCache(reference, parentEntity) + + return parentEntity } } class OptionalSuspendAccessor, REF : Any>( - private val reference: Column, - private val factory: R2dbcEntityClass, - private val entity: R2dbcEntity<*> + internal val reference: Column, + internal val factory: R2dbcEntityClass, + internal val entity: R2dbcEntity<*> ) { operator fun getValue(thisRef: Any?, property: KProperty<*>): OptionalSuspendAccessor { return this @@ -147,13 +172,19 @@ class OptionalSuspendAccessor, REF : Any>( return null } - @Suppress("UNCHECKED_CAST") - val parentId = when { - refValue is EntityID<*> && reference.referee == factory.table.id -> refValue as EntityID - else -> error("Reference column ${reference.name} does not point to ${factory.table.id}") + val parentEntity = when { + reference.referee == factory.table.id -> { + @Suppress("UNCHECKED_CAST") + factory.findById(refValue as EntityID) + } + reference.referee?.table == factory.table -> { + @Suppress("UNCHECKED_CAST") + val refereeColumn = reference.referee!! as Column + factory.find { refereeColumn eq refValue }.singleOrNull() + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") } - val parentEntity = factory.findById(parentId) entity.storeReferenceInCache(reference, parentEntity) return parentEntity diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/EntityCache.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/EntityCache.kt index 3aa7d55e7a..0a74dfb204 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/EntityCache.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/EntityCache.kt @@ -49,16 +49,7 @@ class EntityCache(private val transaction: Transaction) { val diff = value - field field = value if (diff < 0) { - data.values.forEach { map -> - val sizeExceed = map.size - value - if (sizeExceed > 0) { - val iterator = map.iterator() - repeat(sizeExceed) { - iterator.next() - iterator.remove() - } - } - } + data.values.forEach { it.trimToFirst(value) } } } @@ -353,3 +344,21 @@ fun Transaction.flushCache(): List> { return newEntities } } + +/** + * Drops entries from the front of this map until its [size] is at most [maxSize]. + * + * Extracted from `EntityCache.maxEntitiesToStore`'s setter so the setter reads as intent + * ("trim each per-table map to the new max") rather than carrying the iterator mechanics inline. + * Relies on insertion-order iteration of the per-table cache (see `EntityCache.LimitedHashMap`) + * to evict the oldest entries first. + */ +private fun MutableMap.trimToFirst(maxSize: Int) { + val sizeExceed = size - maxSize + if (sizeExceed <= 0) return + val iterator = iterator() + repeat(sizeExceed) { + iterator.next() + iterator.remove() + } +} diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt index a46cc60030..05fbfc9f24 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt @@ -12,11 +12,13 @@ import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.inTopLevelTransaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectTest -import org.jetbrains.exposed.v1.tests.shared.* -import org.junit.jupiter.api.Tag +import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.tests.shared.assertEquals +import org.jetbrains.exposed.v1.tests.shared.assertFalse +import org.jetbrains.exposed.v1.tests.shared.expectException import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.sql.Connection @@ -258,7 +260,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testInsertNonChildWithoutFlush() { withTables(Boards, Posts, Categories) { val board = Board.new { name = "irrelevant" } @@ -268,7 +269,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testThatQueriesWithinOtherQueryIteratorWorksFine() { withTables(Boards, Posts, Categories) { val board1 = Board.new { name = "irrelevant" } @@ -283,7 +283,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testInsertChildWithFlush() { withTables(Boards, Posts, Categories) { val parent = Post.new { this.category = Category.new { title = "title" } } @@ -295,7 +294,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testInsertChildWithChild() { withTables(Boards, Posts, Categories) { val parent = Post.new { this.category = Category.new { title = "title1" } } @@ -309,7 +307,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testOptionalReferrersWithDifferentKeys() { withTables(Boards, Posts, Categories) { val board = Board.new { name = "irrelevant" } @@ -326,7 +323,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testErrorOnSetToDeletedEntity() { withTables(Boards) { expectException { @@ -338,7 +334,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLDelete() { withTables(Boards) { val board1 = Board.new { name = "irrelevant" } @@ -354,7 +349,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLUpdate() { withTables(Boards) { val board1 = Board.new { name = "irrelevant" } @@ -387,7 +381,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testCacheInvalidatedOnDSLUpsert() { withTables(Items) { testDb -> val oldPrice = 20.0 @@ -427,7 +420,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testDaoFindByIdAndUpdate() { withTables(Items) { val oldPrice = 20.0 @@ -457,7 +449,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testDaoFindSingleByAndUpdate() { withTables(Items) { val oldPrice = 20.0 @@ -516,7 +507,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testOneToOneReference() { withTables(Humans, Users) { val user = User.create("testUser") @@ -537,7 +527,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) @Timeout(value = 5000, unit = TimeUnit.MILLISECONDS) fun testSelfReferences() { withTables(SelfReferenceTable) { @@ -549,7 +538,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testNonEntityIdReference() { withTables(Posts, Boards, Categories) { val category1 = Category.new { @@ -574,7 +562,6 @@ class EntityTests : DatabaseTestsBase() { // https://github.com/JetBrains/Exposed/issues/439 @Test - @Tag(MISSING_R2DBC_TEST) fun callLimitOnRelationDoesntMutateTheCachedValue() { withTables(Posts, Boards, Categories) { addLogger(StdOutSqlLogger) // this is left in on purpose for flaky tests @@ -602,7 +589,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testOrderByOnEntities() { withTables(Categories) { Categories.deleteAll() @@ -617,7 +603,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testThatUpdateOfInsertedEntitiesGoesBeforeAnInsert() { withTables(Categories, Posts, Boards) { val category1 = Category.new { @@ -673,7 +658,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testNewIdWithGet() { // SQL Server doesn't support an explicit id for auto-increment table withTables(listOf(TestDB.SQLSERVER), Parents, Children) { @@ -694,7 +678,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun `newly created entity flushed successfully`() { withTables(Boards) { val board = Board.new { name = "Board1" }.apply { @@ -709,7 +692,6 @@ class EntityTests : DatabaseTestsBase() { inTopLevelTransaction(null, statement = statement) @Test - @Tag(MISSING_R2DBC_TEST) fun sharingEntityBetweenTransactions() { withTables(Humans) { val human1 = newTransaction { @@ -845,7 +827,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadReferencesOnASizedIterable() { withTables(Regions, Schools) { val region1 = Region.new { @@ -887,7 +868,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testIterationOverSizedIterableWithPreload() { fun HashMap>.assertEachQueryExecutedOnlyOnce() { forEach { (statement, stats) -> @@ -955,7 +935,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadReferencesOnAnEntity() { withTables(Regions, Schools) { val region1 = Region.new { @@ -988,7 +967,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferencesOnASizedIterable() { withTables(Regions, Schools) { val region1 = Region.new { @@ -1029,7 +1007,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferencesOnAnEntity() { withTables(Regions, Schools) { val region1 = Region.new { @@ -1060,7 +1037,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadReferrersOnASizedIterable() { withTables(Regions, Schools, Students) { val region1 = Region.new { @@ -1122,7 +1098,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadReferrersOnAnEntity() { withTables(Regions, Schools, Students) { val region1 = Region.new { @@ -1163,7 +1138,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadOptionalReferrersOnASizedIterable() { withTables(Regions, Schools, Students, Detentions) { val region1 = Region.new { @@ -1212,7 +1186,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadInnerTableLinkOnASizedIterable() { withTables(Regions, Schools, Holidays, SchoolHolidays) { val now = System.currentTimeMillis() @@ -1274,7 +1247,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadInnerTableLinkOnAnEntity() { withTables(Regions, Schools, Holidays, SchoolHolidays) { val now = System.currentTimeMillis() @@ -1334,7 +1306,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadRelationAtDepth() { withTables(Regions, Schools, Holidays, SchoolHolidays, Students, Notes) { val region1 = Region.new { @@ -1378,7 +1349,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadBackReferrenceOnASizedIterable() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1424,7 +1394,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun preloadBackReferrenceOnAnEntity() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1469,7 +1438,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun `test reference cache doesn't fully invalidated on set entity reference`() { withTables(Regions, Schools, Students, StudentBios) { val region1 = Region.new { @@ -1502,7 +1470,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun `test nested entity initialization`() { withTables(Posts, Categories, Boards) { val post = Post.new { @@ -1529,7 +1496,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testExplicitEntityConstructor() { var createBoardCalled = false fun createBoard(id: EntityID): Board { @@ -1564,7 +1530,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testSelectFromStringIdTableWithPrimaryKeyByColumn() { withTables(RequestsTable) { Request.new { @@ -1589,7 +1554,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testDatabaseGeneratedValues() { withTables(excludeSettings = listOf(TestDB.SQLITE), CreditCards) { testDb -> when (testDb) { @@ -1648,7 +1612,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testEntityIdParam() { withTables(CreditCards) { val newCard = CreditCard.new { @@ -1693,7 +1656,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testEagerLoadingWithStringParentId() { withTables(Countries, Dishes, configure = { keepLoadedReferencesOutOfTransaction = true }) { val lebanonId = Countries.insertAndGetId { @@ -1763,7 +1725,6 @@ class EntityTests : DatabaseTestsBase() { * another column that is a unique index. */ @Test - @Tag(MISSING_R2DBC_TEST) fun testEagerLoadingWithReferenceDifferentFromParentId() { withTables(Customers, Orders, configure = { keepLoadedReferencesOutOfTransaction = true }) { val customer1 = Customer.new { @@ -1807,7 +1768,6 @@ class EntityTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testDifferentEntitiesMappedToTheSameTable() { withTables(TestTable) { val entityA = TestEntityA.new { From 397124f4ae6024799a8605dfc48e9ccd79cb35ff Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Thu, 30 Apr 2026 09:58:01 +0200 Subject: [PATCH 4/7] feat: port JDBC DAO entity tests to R2DBC --- .../dao/r2dbc/tests/demo/dao/SamplesDao.kt | 90 +++ .../tests/h2/R2dbcEntityReferenceCacheTest.kt | 549 ++++++++++++++++++ .../tests/h2/R2dbcMultiDatabaseEntityTest.kt | 230 ++++++++ .../shared/R2dbcEntityCacheRefreshTests.kt | 207 +++++++ .../tests/shared/R2dbcEntityCacheTests.kt | 353 +++++++++++ .../R2dbcEntityFieldWithTransformTest.kt | 172 ++++++ .../r2dbc/tests/shared/R2dbcEntityHookTest.kt | 374 ++++++++++++ .../r2dbc/tests/shared/R2dbcEntityTests.kt | 4 - .../tests/shared/R2dbcEntityWithBlobTests.kt | 54 ++ .../tests/shared/R2dbcForeignIdEntityTest.kt | 99 ++++ .../shared/R2dbcJavaUUIDTableEntityTest.kt | 201 +++++++ .../shared/R2dbcLongIdTableEntityTest.kt | 152 +++++ .../tests/shared/R2dbcNonAutoIncEntities.kt | 134 +++++ .../tests/shared/R2dbcOrderedReferenceTest.kt | 249 ++++++++ .../tests/shared/R2dbcSelfReferenceTest.kt | 105 ++++ .../shared/R2dbcUIntIdTableEntityTest.kt | 153 +++++ .../shared/R2dbcULongIdTableEntityTest.kt | 157 +++++ .../tests/shared/R2dbcUuidTableEntityTest.kt | 248 ++++++++ .../dao/r2dbc/tests/shared/R2dbcViaTest.kt | 399 +++++++++++++ .../R2dbcWarmUpLinkedReferencesTests.kt | 69 +++ .../exposed/r2dbc/dao/CompositeEntity.kt | 13 + .../r2dbc/dao/EntityFieldWithTransform.kt | 40 ++ .../exposed/r2dbc/dao/R2dbcEntity.kt | 33 +- .../exposed/r2dbc/dao/R2dbcEntityCache.kt | 106 ++-- .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 303 +++++++++- .../exposed/r2dbc/dao/R2dbcEntityHook.kt | 135 +++++ .../dao/R2dbcEntityLifecycleInterceptor.kt | 37 +- .../jetbrains/exposed/r2dbc/dao/UIntEntity.kt | 12 + .../exposed/r2dbc/dao/ULongEntity.kt | 12 + .../jetbrains/exposed/r2dbc/dao/UuidEntity.kt | 16 + .../exposed/r2dbc/dao/java/UUIDEntity.kt | 15 + .../dao/relationships/R2dbcEagerLoading.kt | 95 ++- .../dao/relationships/R2dbcInnerTableLink.kt | 13 + .../r2dbc/dao/relationships/R2dbcReferrers.kt | 122 ++-- .../R2dbcRelationshipExtensions.kt | 54 +- .../dao/relationships/SuspendAccessor.kt | 59 +- .../exposed/v1/dao/InnerTableLink.kt | 2 +- .../sql/tests/shared/dml/DMLTestsData.kt | 5 +- .../r2dbc/sql/tests/shared/dml/InsertTests.kt | 3 +- .../exposed/v1/tests/demo/dao/SamplesDao.kt | 3 - .../v1/tests/h2/EntityReferenceCacheTest.kt | 3 - .../v1/tests/h2/MultiDatabaseEntityTest.kt | 3 - .../entities/EntityCacheRefreshTests.kt | 3 - .../tests/shared/entities/EntityCacheTests.kt | 3 - .../entities/EntityFieldWithTransformTest.kt | 3 - .../tests/shared/entities/EntityHookTest.kt | 3 - .../shared/entities/EntityWithBlobTests.kt | 3 - .../shared/entities/ForeignIdEntityTest.kt | 3 - .../entities/JavaUUIDTableEntityTest.kt | 3 - .../shared/entities/LongIdTableEntityTest.kt | 3 - .../shared/entities/NonAutoIncEntities.kt | 3 - .../shared/entities/OrderedReferenceTest.kt | 4 - .../shared/entities/SelfReferenceTest.kt | 3 - .../shared/entities/UIntIdTableEntityTest.kt | 3 - .../shared/entities/ULongIdTableEntityTest.kt | 3 - .../shared/entities/UuidTableEntityTest.kt | 3 - .../v1/tests/shared/entities/ViaTest.kt | 3 - .../entities/WarmUpLinkedReferencesTests.kt | 3 - 58 files changed, 4871 insertions(+), 261 deletions(-) create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/demo/dao/SamplesDao.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcEntityReferenceCacheTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcMultiDatabaseEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheRefreshTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityFieldWithTransformTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityHookTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityWithBlobTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcForeignIdEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcJavaUUIDTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcLongIdTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcNonAutoIncEntities.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcOrderedReferenceTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcSelfReferenceTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUuidTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcViaTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcWarmUpLinkedReferencesTests.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/CompositeEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/EntityFieldWithTransform.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityHook.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UIntEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/ULongEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UuidEntity.kt create mode 100644 exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/java/UUIDEntity.kt rename exposed-r2dbc-tests/src/{test => main}/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt (98%) diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/demo/dao/SamplesDao.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/demo/dao/SamplesDao.kt new file mode 100644 index 0000000000..275a0c6d3f --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/demo/dao/SamplesDao.kt @@ -0,0 +1,90 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.demo.dao + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.v1.core.StdOutSqlLogger +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.greaterEq +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.Assumptions +import kotlin.test.Test + +object Users : IntIdTable() { + val name = varchar("name", 50).index() + val city = reference("city", Cities) + val age = integer("age") +} + +object Cities : IntIdTable() { + val name = varchar("name", 50) +} + +class User(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) + + var name by Users.name + val city by City referencedOnSuspend Users.city + var age by Users.age +} + +class City(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Cities) + + var name by Cities.name + val users by User referrersOnSuspend Users.city +} + +fun main() = runBlocking { + Assumptions.assumeTrue(TestDB.H2_V2 in TestDB.enabledDialects()) + R2dbcDatabase.connect("r2dbc:h2:mem:///test", user = "root", password = "") + + suspendTransaction { + addLogger(StdOutSqlLogger) + + SchemaUtils.create(Cities, Users) + + val stPete = City.new { + name = "St. Petersburg" + } + + val munich = City.new { + name = "Munich" + } + + User.new { + name = "a" + city set stPete + age = 5 + } + + User.new { + name = "b" + city set stPete + age = 27 + } + + User.new { + name = "c" + city set munich + age = 42 + } + + println("Cities: ${City.all().toList().joinToString { it.name }}") + println("Users in ${stPete.name}: ${stPete.users().toList().joinToString { it.name }}") + println("Adults: ${User.find { Users.age greaterEq 18 }.toList().joinToString { it.name }}") + } +} + +class SamplesDao { + @Test + fun ensureSamplesDoesntCrash() { + main() + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcEntityReferenceCacheTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcEntityReferenceCacheTest.kt new file mode 100644 index 0000000000..e3b32a2473 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcEntityReferenceCacheTest.kt @@ -0,0 +1,549 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.h2 + +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.dao.r2dbc.tests.demo.dao.Cities +import org.jetbrains.exposed.dao.r2dbc.tests.demo.dao.City +import org.jetbrains.exposed.dao.r2dbc.tests.demo.dao.User +import org.jetbrains.exposed.dao.r2dbc.tests.demo.dao.Users +import org.jetbrains.exposed.dao.r2dbc.tests.shared.EntityTestsData +import org.jetbrains.exposed.dao.r2dbc.tests.shared.R2dbcEntityTests +import org.jetbrains.exposed.dao.r2dbc.tests.shared.VNumber +import org.jetbrains.exposed.dao.r2dbc.tests.shared.VString +import org.jetbrains.exposed.dao.r2dbc.tests.shared.ViaTestData +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.Assumptions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull +import kotlin.properties.Delegates +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNotNull + +class R2dbcEntityReferenceCacheTest : R2dbcDatabaseTestsBase() { + private val db by lazy { + TestDB.H2_V2.connect() + } + + private val dbWithCache by lazy { + TestDB.H2_V2.connect { + keepLoadedReferencesOutOfTransaction = true + } + } + + private suspend fun executeOnH2(vararg tables: Table, body: suspend () -> Unit) { + Assumptions.assumeTrue(TestDB.H2_V2 in TestDB.enabledDialects()) + var testWasStarted = false + suspendTransaction(db) { + SchemaUtils.create(*tables) + testWasStarted = true + } + Assumptions.assumeTrue(testWasStarted) + if (testWasStarted) { + try { + body() + } finally { + suspendTransaction(db) { + SchemaUtils.drop(*tables) + } + } + } + } + + @Test + fun `test referenceOn works out of transaction`() = runTest { + var y1: EntityTestsData.YEntity by Delegates.notNull() + var b1: EntityTestsData.BEntity by Delegates.notNull() + executeOnH2(EntityTestsData.XTable, EntityTestsData.YTable) { + suspendTransaction(db) { + y1 = EntityTestsData.YEntity.new { + this.x = true + } + b1 = EntityTestsData.BEntity.new { + this.b1 = true + this.y set y1 + } + } + assertFails { y1.b() } + assertFails { b1.y() } + + suspendTransaction(dbWithCache) { + y1.refresh() + b1.refresh() + assertEquals(b1.id, y1.b()?.id) + assertEquals(y1.id, b1.y()?.id) + } + + assertEquals(b1.id, y1.b()?.id) + assertEquals(y1.id, b1.y()?.id) + } + } + + @Test + fun `test backReferencedOn & optionalBackReferencedOn work out of transaction via load`() = runTest { + var y1: EntityTestsData.YEntity by Delegates.notNull() + var b1: EntityTestsData.BEntity by Delegates.notNull() + executeOnH2(EntityTestsData.XTable, EntityTestsData.YTable) { + suspendTransaction(db) { + y1 = EntityTestsData.YEntity.new {} + b1 = EntityTestsData.BEntity.new { + this.y set y1 + } + } + // R2DBC: property access returns a `suspend () -> ...` accessor — only invocation + // performs the DB lookup that must fail when there's no transaction. + assertFails { y1.b() } + assertFails { y1.bOpt() } + + suspendTransaction(dbWithCache) { + y1.refresh() + b1.refresh() + y1.load(EntityTestsData.YEntity::b, EntityTestsData.YEntity::bOpt) + } + + assertEquals(b1.id, y1.b()?.id) + assertEquals(b1.id, y1.bOpt()?.id) + } + } + + @Test + fun `test optionalBackReferencedOn and optionalReferencedOn work when value is missing`() = runTest { + var y1: EntityTestsData.YEntity by Delegates.notNull() + var b1: EntityTestsData.BEntity by Delegates.notNull() + executeOnH2(EntityTestsData.XTable, EntityTestsData.YTable) { + suspendTransaction(db) { + y1 = EntityTestsData.YEntity.new {} + b1 = EntityTestsData.BEntity.new {} + } + + suspendTransaction(dbWithCache) { + y1.refresh() + b1.refresh() + y1.load(EntityTestsData.YEntity::bOpt) + b1.load(EntityTestsData.BEntity::y) + } + + // R2DBC: property access returns the accessor lambda — invoke it to get the actual + // (null) value pinned in the per-entity reference cache by `load(...)`. + assertNull(y1.bOpt()) + assertNull(b1.y()) + } + } + + @Test + fun `test referenceOn works out of transaction via with`() = runTest { + var b1: R2dbcEntityTests.Board by Delegates.notNull() + var p1: R2dbcEntityTests.Post by Delegates.notNull() + var p2: R2dbcEntityTests.Post by Delegates.notNull() + executeOnH2(R2dbcEntityTests.Boards, R2dbcEntityTests.Posts, R2dbcEntityTests.Categories) { + suspendTransaction(db) { + b1 = R2dbcEntityTests.Board.new { + name = "test-board" + } + p1 = R2dbcEntityTests.Post.new { + board set b1 + } + p2 = R2dbcEntityTests.Post.new { + board set b1 + } + } + assertFails { b1.posts().toList() } + assertFails { p1.board()?.id } + assertFails { p2.board()?.id } + + suspendTransaction(dbWithCache) { + b1.refresh() + p1.refresh() + p2.refresh() + listOf(p1, p2).with(R2dbcEntityTests.Post::board) + } + + assertEquals(b1.id, p1.board()?.id) + assertEquals(b1.id, p2.board()?.id) + } + } + + @Test + fun `test referrersOn works out of transaction`() = runTest { + var b1: R2dbcEntityTests.Board by Delegates.notNull() + var p1: R2dbcEntityTests.Post by Delegates.notNull() + var p2: R2dbcEntityTests.Post by Delegates.notNull() + executeOnH2(R2dbcEntityTests.Boards, R2dbcEntityTests.Posts, R2dbcEntityTests.Categories) { + suspendTransaction(db) { + b1 = R2dbcEntityTests.Board.new { + name = "test-board" + } + p1 = R2dbcEntityTests.Post.new { + board set b1 + } + p2 = R2dbcEntityTests.Post.new { + board set b1 + } + } + + assertFails { b1.posts().toList() } + assertFails { p1.board()?.id } + assertFails { p2.board()?.id } + + suspendTransaction(dbWithCache) { + b1.refresh() + p1.refresh() + p2.refresh() + assertEquals(b1.id, p1.board()?.id) + assertEquals(b1.id, p2.board()?.id) + assertEqualCollections(b1.posts().map { it.id }.toList(), p1.id, p2.id) + } + + assertEquals(b1.id, p1.board()?.id) + assertEquals(b1.id, p2.board()?.id) + assertEqualCollections(b1.posts().map { it.id }.toList(), p1.id, p2.id) + } + } + + @Test + fun `test optionalReferrersOn works out of transaction via warmup`() = runTest { + var b1: R2dbcEntityTests.Board by Delegates.notNull() + var p1: R2dbcEntityTests.Post by Delegates.notNull() + var p2: R2dbcEntityTests.Post by Delegates.notNull() + executeOnH2(R2dbcEntityTests.Boards, R2dbcEntityTests.Posts, R2dbcEntityTests.Categories) { + suspendTransaction(db) { + b1 = R2dbcEntityTests.Board.new { + name = "test-board" + } + p1 = R2dbcEntityTests.Post.new { + board set b1 + } + p2 = R2dbcEntityTests.Post.new { + board set b1 + } + } + assertFails { b1.posts().toList() } + assertFails { p1.board()?.id } + assertFails { p2.board()?.id } + + suspendTransaction(dbWithCache) { + b1.refresh() + p1.refresh() + p2.refresh() + b1.load(R2dbcEntityTests.Board::posts) + assertEqualCollections(b1.posts().map { it.id }, p1.id, p2.id) + } + + assertEqualCollections(b1.posts().map { it.id }, p1.id, p2.id) + } + } + + @Test + fun `test referrersOn works out of transaction via warmup`() = runTest { + var c1: City by Delegates.notNull() + var u1: User by Delegates.notNull() + var u2: User by Delegates.notNull() + executeOnH2(Cities, Users) { + suspendTransaction(dbWithCache) { + c1 = City.new { + name = "Seoul" + } + u1 = User.new { + name = "a" + city set c1 + age = 5 + } + u2 = User.new { + name = "b" + city set c1 + age = 27 + } + City.all().with(City::users).toList() + } + assertEqualCollections(c1.users().map { it.id }.toList(), u1.id, u2.id) + } + } + + @Test + fun `test via reference out of transaction`() = runTest { + var n: VNumber by Delegates.notNull() + var s1: VString by Delegates.notNull() + var s2: VString by Delegates.notNull() + executeOnH2(*ViaTestData.allTables) { + suspendTransaction(db) { + n = VNumber.new { number = 10 } + s1 = VString.new { text = "aaa" } + s2 = VString.new { text = "bbb" } + n.connectedStrings set listOf(s1, s2) + } + + assertFails { n.connectedStrings().toList() } + suspendTransaction(dbWithCache) { + n.refresh() + s1.refresh() + s2.refresh() + assertEqualCollections(n.connectedStrings().map { it.id }.toList(), s1.id, s2.id) + } + assertEqualCollections(n.connectedStrings().map { it.id }, s1.id, s2.id) + } + } + + @Test + fun `test via reference load out of transaction`() = runTest { + var n: VNumber by Delegates.notNull() + var s1: VString by Delegates.notNull() + var s2: VString by Delegates.notNull() + executeOnH2(*ViaTestData.allTables) { + suspendTransaction(db) { + n = VNumber.new { number = 10 } + s1 = VString.new { text = "aaa" } + s2 = VString.new { text = "bbb" } + n.connectedStrings set listOf(s1, s2) + } + + assertFails { n.connectedStrings().toList() } + suspendTransaction(dbWithCache) { + n.refresh() + s1.refresh() + s2.refresh() + n.load(VNumber::connectedStrings) + assertEqualCollections(n.connectedStrings().map { it.id }.toList(), s1.id, s2.id) + } + assertEqualCollections(n.connectedStrings().map { it.id }.toList(), s1.id, s2.id) + + suspendTransaction(dbWithCache) { + n.connectedStrings set listOf(s1) + assertEqualCollections(n.connectedStrings().map { it.id }.toList(), s1.id) + n.load(VNumber::connectedStrings) + assertEqualCollections(n.connectedStrings().map { it.id }.toList(), s1.id) + } + } + } + + object Customers : IntIdTable() { + val name = varchar("name", 10) + } + + object Orders : IntIdTable() { + val customer = reference("customer", Customers) + val ref = varchar("name", 10) + } + + object OrderItems : IntIdTable() { + val order = reference("order", Orders) + val sku = varchar("sky", 10) + } + + object Addresses : IntIdTable() { + val customer = reference("customer", Customers) + val street = varchar("street", 10) + } + + object Roles : IntIdTable() { + val name = varchar("name", 10) + } + + object CustomerRoles : IntIdTable() { + val customer = reference("customer", Customers, onDelete = ReferenceOption.CASCADE) + val role = reference("role", Roles, onDelete = ReferenceOption.CASCADE) + } + + class Customer(id: EntityID) : IntR2dbcEntity(id) { + var name by Customers.name + val orders by Order.referrersOnSuspend(Orders.customer) + val addresses by Address.referrersOnSuspend(Addresses.customer) + val customerRoles by CustomerRole.referrersOnSuspend(CustomerRoles.customer) + + companion object : IntR2dbcEntityClass(Customers) + } + + class Order(id: EntityID) : IntR2dbcEntity(id) { + var ref by Orders.ref + val customer by Customer.referencedOnSuspend(Orders.customer) + val items by OrderItem.referrersOnSuspend(OrderItems.order) + + companion object : IntR2dbcEntityClass(Orders) + } + + class OrderItem(id: EntityID) : IntR2dbcEntity(id) { + var sku by OrderItems.sku + val order by Order.referencedOnSuspend(OrderItems.order) + + companion object : IntR2dbcEntityClass(OrderItems) + } + + class Address(id: EntityID) : IntR2dbcEntity(id) { + var street by Addresses.street + val customer by Customer.referencedOnSuspend(Addresses.customer) + + companion object : IntR2dbcEntityClass
(Addresses) + } + + class Role(id: EntityID) : IntR2dbcEntity(id) { + var name by Roles.name + + companion object : IntR2dbcEntityClass(Roles) + } + + class CustomerRole(id: EntityID) : IntR2dbcEntity(id) { + val customer by Customer.referencedOnSuspend(CustomerRoles.customer) + val role by Role.referencedOnSuspend(CustomerRoles.role) + + companion object : IntR2dbcEntityClass(CustomerRoles) + } + + @Test + fun `dont flush indirectly related entities on insert`() { + withTables(Customers, Orders, OrderItems, Addresses) { + val customer1 = Customer.new { name = "Test" } + val order1 = Order.new { + customer set customer1 + ref = "Test" + } + + val orderItem1 = OrderItem.new { + order set order1 + sku = "Test" + } + + assertEqualCollections(listOf(order1), customer1.orders().toList()) + assertEqualCollections(emptyList(), customer1.addresses().toList()) + assertNotNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNotNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + + assertEquals(1, order1.items().toList().size) + assertEquals(orderItem1, order1.items().single()) + assertNotNull(entityCache.getReferrers(order1.id, OrderItems.order)) + + Address.new { + customer set customer1 + street = "Test" + } + + flushCache() + + assertNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + assertNotNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNotNull(entityCache.getReferrers(order1.id, OrderItems.order)) + + val customer2 = Customer.new { name = "Test2" } + + flushCache() + + assertNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + assertNotNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNull(entityCache.getReferrers
(customer2.id, Addresses.customer)) + assertNull(entityCache.getReferrers(customer2.id, Orders.customer)) + + assertNotNull(entityCache.getReferrers(order1.id, OrderItems.order)) + } + } + + @Test + fun `dont flush indirectly related entities on delete`() { + withTables(Customers, Orders, OrderItems, Addresses) { + val customer1 = Customer.new { name = "Test" } + val order1 = Order.new { + customer set customer1 + ref = "Test" + } + + val order2 = Order.new { + customer set customer1 + ref = "Test2" + } + + OrderItem.new { + order set order1 + sku = "Test" + } + + val orderItem2 = OrderItem.new { + order set order2 + sku = "Test2" + } + + Address.new { + customer set customer1 + street = "Test" + } + + flushCache() + + // Load caches + customer1.orders().toList() + customer1.addresses().toList() + order1.items().toList() + order2.items().toList() + + assertNotNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNotNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + assertNotNull(entityCache.getReferrers(order1.id, OrderItems.order)) + assertNotNull(entityCache.getReferrers(order2.id, OrderItems.order)) + + orderItem2.delete() + + assertNotNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNotNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + assertNull(entityCache.getReferrers(order1.id, OrderItems.order)) + assertNull(entityCache.getReferrers(order2.id, OrderItems.order)) + + // Load caches + customer1.orders().toList() + customer1.addresses().toList() + order1.items().toList() + order2.items().toList() + + order2.delete() + assertNull(entityCache.getReferrers(customer1.id, Orders.customer)) + assertNotNull(entityCache.getReferrers
(customer1.id, Addresses.customer)) + assertNull(entityCache.getReferrers(order1.id, OrderItems.order)) + assertNull(entityCache.getReferrers(order2.id, OrderItems.order)) + } + } + + @Test + fun `dont flush indirectly related entities with inner table`() { + withTables(Customers, Roles, CustomerRoles) { + val customer1 = Customer.new { name = "Test" } + val role1 = Role.new { name = "Test" } + val customerRole1 = CustomerRole.new { + customer set customer1 + role set role1 + } + + flushCache() + assertEqualCollections(listOf(customerRole1), customer1.customerRoles().toList()) + val role2 = Role.new { name = "Test2" } + + flushCache() + assertNotNull(entityCache.getReferrers(customer1.id, CustomerRoles.customer)) + + val customerRole2 = CustomerRole.new { + customer set customer1 + role set role2 + } + flushCache() + + assertNull(entityCache.getReferrers
(customer1.id, CustomerRoles.customer)) + + assertEqualCollections(listOf(customerRole1, customerRole2), customer1.customerRoles().toList()) + assertNotNull(entityCache.getReferrers
(customer1.id, CustomerRoles.customer)) + + role2.delete() + assertNull(entityCache.getReferrers
(customer1.id, CustomerRoles.customer)) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcMultiDatabaseEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcMultiDatabaseEntityTest.kt new file mode 100644 index 0000000000..91a1184e83 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/h2/R2dbcMultiDatabaseEntityTest.kt @@ -0,0 +1,230 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.h2 + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.flow.all +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.dao.r2dbc.tests.shared.EntityTestsData +import org.jetbrains.exposed.v1.core.DatabaseConfig.Companion.invoke +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabaseConfig +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assumptions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import kotlin.properties.Delegates +import kotlin.test.Test + +class R2dbcMultiDatabaseEntityTest : R2dbcDatabaseTestsBase() { + private val db1 by lazy { + R2dbcDatabase.connect( + "r2dbc:h2:mem:///db1;DB_CLOSE_DELAY=-1;", user = "root", password = "", + databaseConfig = R2dbcDatabaseConfig { + defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED + } + ) + } + private val db2 by lazy { + R2dbcDatabase.connect( + "r2dbc:h2:mem:///db2;DB_CLOSE_DELAY=-1;", user = "root", password = "", + databaseConfig = R2dbcDatabaseConfig { + defaultR2dbcIsolationLevel = IsolationLevel.READ_COMMITTED + } + ) + } + private var currentDB: R2dbcDatabase? = null + + @BeforeEach + fun before() = runBlocking { + Assumptions.assumeTrue(TestDB.H2_V2 in TestDB.enabledDialects()) + TransactionManager.currentOrNull()?.let { + currentDB = it.db + } + suspendTransaction(db1) { + SchemaUtils.create(EntityTestsData.XTable, EntityTestsData.YTable) + } + suspendTransaction(db2) { + SchemaUtils.create(EntityTestsData.XTable, EntityTestsData.YTable) + } + } + + @AfterEach + fun after() = runBlocking { + if (TestDB.H2_V2 in TestDB.enabledDialects()) { + suspendTransaction(db1) { + SchemaUtils.drop(EntityTestsData.XTable, EntityTestsData.YTable) + } + suspendTransaction(db2) { + SchemaUtils.drop(EntityTestsData.XTable, EntityTestsData.YTable) + } + } + } + + @Test + fun testSimpleCreateEntitiesInDifferentDatabase() = runTest { + suspendTransaction(db1) { + EntityTestsData.XEntity.new { + this.b1 = true + } + } + + suspendTransaction(db2) { + EntityTestsData.XEntity.new { + this.b1 = false + } + + EntityTestsData.XEntity.new { + this.b1 = false + } + } + + suspendTransaction(db1) { + assertEquals(1L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().single().b1) + } + + suspendTransaction(db2) { + assertEquals(2L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().all { !it.b1 }) + } + } + + @Test + fun testEmbeddedInsertsInDifferentDatabase() = runTest { + suspendTransaction(db1) { + EntityTestsData.XEntity.new { + this.b1 = true + } + + assertEquals(1L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().single().b1) + + suspendTransaction(db2) { + assertEquals(0L, EntityTestsData.XEntity.all().count()) + EntityTestsData.XEntity.new { + this.b1 = false + } + + EntityTestsData.XEntity.new { + this.b1 = false + } + assertEquals(2L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().all { !it.b1 }) + } + + assertEquals(1L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().single().b1) + } + } + + @Test + fun testEmbeddedInsertsInDifferentDatabaseDepth2() = runTest { + suspendTransaction(db1) { + EntityTestsData.XEntity.new { + this.b1 = true + } + + assertEquals(1L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().single().b1) + + suspendTransaction(db2) { + assertEquals(0L, EntityTestsData.XEntity.all().count()) + EntityTestsData.XEntity.new { + this.b1 = false + } + + EntityTestsData.XEntity.new { + this.b1 = false + } + assertEquals(2L, EntityTestsData.XEntity.all().count()) + assertEquals(true, EntityTestsData.XEntity.all().all { !it.b1 }) + + suspendTransaction(db1) { + EntityTestsData.XEntity.new { + this.b1 = true + } + + EntityTestsData.XEntity.new { + this.b1 = false + } + assertEquals(3L, EntityTestsData.XEntity.all().count()) + } + assertEquals(2L, EntityTestsData.XEntity.all().count()) + } + + assertEquals(3L, EntityTestsData.XEntity.all().count()) + assertEqualLists(listOf(true, true, false), EntityTestsData.XEntity.all().map { it.b1 }.toList()) + } + } + + @Test + fun crossReferencesAllowedForEntitiesFromSameDatabase() = runTest { + var db1b1 by Delegates.notNull() + var db2b1 by Delegates.notNull() + var db1y1 by Delegates.notNull() + var db2y1 by Delegates.notNull() + suspendTransaction(db1) { + db1b1 = EntityTestsData.BEntity.new(1) { } + + suspendTransaction(db2) { + assertEquals(0L, EntityTestsData.BEntity.count()) + db2b1 = EntityTestsData.BEntity.new(2) { } + db2y1 = EntityTestsData.YEntity.new("2") { } + db2b1.y set db2y1 + } + assertEquals(1L, EntityTestsData.BEntity.count()) + assertNotNull(EntityTestsData.BEntity[1]) + + db1y1 = EntityTestsData.YEntity.new("1") { } + db1b1.y set db1y1 + + commit() + + suspendTransaction(db2) { + assertNull(EntityTestsData.BEntity.testCache(EntityID(2, EntityTestsData.BEntity.table))) + val b2Reread = EntityTestsData.BEntity.all().single() + assertEquals(db2b1.id, b2Reread.id) + assertEquals(db2y1.id, b2Reread.y()?.id) + b2Reread.y set null + } + } + inTopLevelSuspendTransaction(db1, IsolationLevel.READ_COMMITTED) { + maxAttempts = 1 + assertNull(EntityTestsData.BEntity.testCache(db1b1.id)) + val b1Reread = EntityTestsData.BEntity[db1b1.id] + assertEquals(db1b1.id, b1Reread.id) + assertEquals(db1y1.id, EntityTestsData.YEntity[db1y1.id].id) + assertEquals(db1y1.id, b1Reread.y()?.id) + } + } + + @Test + fun crossReferencesProhibitedForEntitiesFromDifferentDB() = runTest { + Assertions.assertThrows(IllegalStateException::class.java) { + runBlocking { + suspendTransaction(db1) { + val db1b1 = EntityTestsData.BEntity.new(1) { } + + suspendTransaction(db2) { + assertEquals(0L, EntityTestsData.BEntity.count()) + db1b1.y set EntityTestsData.YEntity.new("2") { } + } + } + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheRefreshTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheRefreshTests.kt new file mode 100644 index 0000000000..4dfe567dc0 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheRefreshTests.kt @@ -0,0 +1,207 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.select +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.jetbrains.exposed.v1.r2dbc.update +import org.junit.jupiter.api.Assumptions +import kotlin.test.Test +import kotlin.test.assertEquals + +class R2dbcEntityCacheRefreshTests : R2dbcDatabaseTestsBase() { + val excludedDbs = listOf(TestDB.SQLSERVER) + + object TestTable : IntIdTable("entity_cache_refresh_test") { + val value = integer("value") + } + + class TestEntity(id: EntityID) : IntR2dbcEntity(id) { + var value by TestTable.value + + companion object : IntR2dbcEntityClass(TestTable) + } + + // Extended table for testing partial SELECT with multiple columns + object ExtendedTestTable : IntIdTable("extended_test") { + val value = integer("value") + val name = varchar("name", 50) + } + + class ExtendedTestEntity(id: EntityID) : IntR2dbcEntity(id) { + var value by ExtendedTestTable.value + var name by ExtendedTestTable.name + + companion object : IntR2dbcEntityClass(ExtendedTestTable) + } + + @Test + fun testConcurrentIncrementsWithSelectForUpdate() { + if (dialect in excludedDbs) { + Assumptions.assumeFalse(true) + } + + withTables(TestTable) { + val db = dialect.connect() + + // Create a single entity with initial value 0 + val entityIdValue = suspendTransaction(db = db) { + val entity = TestEntity.new { value = 0 } + + entity.flush() + + entity.id.value + } + + val threadCount = 20 + + runBlocking(Dispatchers.IO) { + List(threadCount) { + launch { + suspendTransaction(db = db, transactionIsolation = IsolationLevel.READ_COMMITTED) { + // This is important line, because it forces DAO to cache + // the value from the beginning of transaction + TestEntity.find { TestTable.id eq entityIdValue }.single() + + val entity = TestEntity.find { TestTable.id eq entityIdValue } + .forUpdate() + .single() + + val currentValue = entity.value + + entity.value = currentValue + 1 + } + } + }.forEach { it.join() } + } + + // Verify all increments were applied + suspendTransaction(db = db) { + val finalEntity = TestEntity[entityIdValue] + assertEquals( + threadCount, + finalEntity.value, + "Expected value to be $threadCount after $threadCount concurrent increments, " + + "but got ${finalEntity.value}. This indicates lost updates due to stale cache." + ) + } + } + } + + /** + * Scenario: + * 1. Transaction T1 reads entity with value A (entity gets cached) + * 2. Transaction T2 updates entity value to B and commits + * 3. Transaction T1 performs SELECT FOR UPDATE on the same entity + * 4. Transaction T1 should see value B, not cached value A + */ + @Test + fun testSelectForUpdateReturnsCurrentData() { + // Skip databases that don't support SELECT FOR UPDATE + if (dialect in excludedDbs) { + Assumptions.assumeFalse(true) + } + + withTables(TestTable) { + val db1 = dialect.connect() + val db2 = dialect.connect() + + val entityId = suspendTransaction(db = db1) { + TestEntity.new { value = 100 }.id + } + + suspendTransaction(db = db1, transactionIsolation = IsolationLevel.READ_COMMITTED) { + val entity = TestEntity[entityId] + assertEquals(100, entity.value, "Initial value should be 100") + + // In a separate transaction, update the value + suspendTransaction(db = db2) { + val entity2 = TestEntity[entityId] + entity2.value = 200 + } + + val entityWithForUpdate = TestEntity.find { TestTable.id eq entityId.value } + .forUpdate() + .single() + + assertEquals( + 200, + entityWithForUpdate.value, + "SELECT FOR UPDATE should return fresh data (200), not cached data (100)" + ) + + assertEquals( + entity.id.value, + entityWithForUpdate.id.value, + "Should be the same entity instance" + ) + } + } + } + + /** + * This test verifies that when users manually create a partial SELECT + * (selecting only some columns) and call wrapRow(), the method performs a selective merge: + * - Columns in the partial SELECT are updated with fresh data + * - Columns not in the partial SELECT retain their previously cached values + * + * This ensures that manual DSL queries combined with wrapRow() work correctly. + */ + @Test + fun testManualPartialSelectMergesWithCachedColumns() { + withTables(ExtendedTestTable) { + val entityId = ExtendedTestEntity.new { + value = 100 + name = "Original" + }.id + commit() + + // Load entity fully (all columns cached) + val fullEntity = ExtendedTestEntity[entityId] + assertEquals(100, fullEntity.value) + assertEquals("Original", fullEntity.name) + + val db2 = db + inTopLevelSuspendTransaction(db = db2) { + ExtendedTestTable.update({ ExtendedTestTable.id eq entityId }) { + it[value] = 200 + it[name] = "Updated" + } + } + + val partialResults = ExtendedTestTable + .select(ExtendedTestTable.id, ExtendedTestTable.value) + .where { ExtendedTestTable.id eq entityId.value } + .map { row -> ExtendedTestEntity.wrapRow(row) } + + val entityFromPartial = partialResults.single() + + // Should see new value, because the new value was fetched from the query + assertEquals( + 200, + entityFromPartial.value, + "Value should be updated from partial SELECT" + ) + + assertEquals( + "Original", + entityFromPartial.name, + "Name should still be accessible from cached data (not in partial SELECT)" + ) + + assertEquals(entityId.value, entityFromPartial.id.value) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheTests.kt new file mode 100644 index 0000000000..49475655b8 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityCacheTests.kt @@ -0,0 +1,353 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.forEach +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table.PrimaryKey +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.exposedLogger +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.deleteAll +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.Assumptions +import org.junit.jupiter.api.Test +import java.sql.SQLException +import java.util.concurrent.atomic.AtomicInteger +import kotlin.collections.toList +import kotlin.random.Random + +class R2dbcEntityCacheTests : R2dbcDatabaseTestsBase() { + object TestTable : IntIdTable("TestCache") { + val value = integer("value") + } + + class TestEntity(id: EntityID) : IntR2dbcEntity(id) { + var value by TestTable.value + + companion object : IntR2dbcEntityClass(TestTable) + } + + @Test + fun testGlobalEntityCacheLimit() = runTest { + Assumptions.assumeTrue(TestDB.H2_V2 in TestDB.enabledDialects()) + val entitiesCount = 25 + val cacheSize = 10 + val db = TestDB.H2_V2.connect { + maxEntitiesToStoreInCachePerEntity = cacheSize + } + + suspendTransaction(db) { + try { + SchemaUtils.create(TestTable) + + repeat(entitiesCount) { + TestEntity.new { + value = Random.nextInt() + } + } + + val allEntities = TestEntity.all().toList() + assertEquals(entitiesCount, allEntities.size) + val allCachedEntities = entityCache.findAll(TestEntity) + assertEquals(cacheSize, allCachedEntities.size) + assertEqualCollections(allEntities.drop(entitiesCount - cacheSize), allCachedEntities) + } finally { + SchemaUtils.drop(TestTable) + } + } + } + + @Test + fun testGlobalEntityCacheLimitZero() = runTest { + Assumptions.assumeTrue(TestDB.H2_V2 in TestDB.enabledDialects()) + val entitiesCount = 25 + val db = TestDB.H2_V2.connect() + val dbNoCache = TestDB.H2_V2.connect { + maxEntitiesToStoreInCachePerEntity = 10 + } + + val entityIds = suspendTransaction(db) { + SchemaUtils.create(TestTable) + + repeat(entitiesCount) { + TestEntity.new { + value = Random.nextInt() + } + } + + val entityIds = TestTable.selectAll().map { it[TestTable.id] }.toList() + val initialStatementCount = statementCount + entityIds.forEach { + TestEntity[it] + } + // All read from cache + assertEquals(initialStatementCount, statementCount) + + entityCache.clear() + // Load all into cache + TestEntity.all().toList() + + entityIds.forEach { + TestEntity[it] + } + assertEquals(initialStatementCount + 1, statementCount) + entityIds + } + + suspendTransaction(dbNoCache) { + debug = true + TestEntity.all().toList() + assertEquals(1, statementCount) + val initialStatementCount = statementCount + entityIds.forEach { + TestEntity[it] + } + assertEquals(initialStatementCount + entitiesCount, statementCount) + SchemaUtils.drop(TestTable) + } + } + + @Test + fun testPerTransactionEntityCacheLimit() { + val entitiesCount = 25 + val cacheSize = 10 + withTables(TestTable) { + entityCache.maxEntitiesToStore = 10 + + repeat(entitiesCount) { + TestEntity.new { + value = Random.nextInt() + } + } + + val allEntities = TestEntity.all().toList() + assertEquals(entitiesCount, allEntities.size) + val allCachedEntities = entityCache.findAll(TestEntity) + assertEquals(cacheSize, allCachedEntities.size) + assertEqualCollections(allEntities.drop(entitiesCount - cacheSize), allCachedEntities) + } + } + + @Test + fun changeEntityCacheMaxEntitiesToStoreInMiddleOfTransaction() { + withTables(TestTable) { + repeat(20) { + TestEntity.new { + value = Random.nextInt() + } + } + entityCache.clear() + + TestEntity.all().limit(15).toList() + assertEquals(15, entityCache.findAll(TestEntity).size) + + entityCache.maxEntitiesToStore = 18 + TestEntity.all().toList() + assertEquals(18, entityCache.findAll(TestEntity).size) + + // Resize current cache + entityCache.maxEntitiesToStore = 10 + assertEquals(10, entityCache.findAll(TestEntity).size) + + entityCache.maxEntitiesToStore = 18 + TestEntity.all().toList() + assertEquals(18, entityCache.findAll(TestEntity).size) + + // Disable cache + entityCache.maxEntitiesToStore = 0 + assertEquals(0, entityCache.findAll(TestEntity).size) + } + } + + @Test + fun `EntityCache should not be cleaned on explicit commit`() { + withTables(TestTable) { + val entity = TestEntity.new { + value = Random.nextInt() + } + assertEquals(entity, TestEntity.testCache(entity.id)) + commit() + assertEquals(entity, TestEntity.testCache(entity.id)) + } + } + + object TableWithDefaultValue : IdTable() { + val value = integer("value") + val valueWithDefault = integer("valueWithDefault") + .default(10) + + override val id: Column> = integer("id") + .clientDefault { Random.nextInt() } + .entityId() + + override val primaryKey: PrimaryKey = PrimaryKey(id) + } + + class TableWithDefaultValueEntity(id: EntityID) : IntR2dbcEntity(id) { + var value by TableWithDefaultValue.value + + var valueWithDefault by TableWithDefaultValue.valueWithDefault + + companion object : IntR2dbcEntityClass(TableWithDefaultValue) + } + + @Test + fun entitiesWithDifferentAmountOfFieldsCouldBeCreated() { + withTables(TableWithDefaultValue) { + TableWithDefaultValueEntity.new { + value = 1 + } + TableWithDefaultValueEntity.new { + value = 2 + valueWithDefault = 1 + } + + // It's the key flush. It must not fail with inconsistent batch insert statement. + // The table also should have client side default value. Otherwise the `writeValues` + // would be extended with default values inside `EntityClass::new()` method. + flushCache() + entityCache.clear() + + val entity = TableWithDefaultValueEntity.find { TableWithDefaultValue.value eq 1 }.first() + assertEquals(10, entity.valueWithDefault) + } + } + + /** + * EXPOSED-886 Changes made to DAO (entity) can be lost on serializable transaction retry (Postgres) + */ + @Test + fun testConcurrentSerializableAccessWithTransactionsRetry() = runBlocking(Dispatchers.IO) { + val testSize = 10 + + // There is no SQLITE R2DBC driver + // Only SQLite complains that the TestTable doesn't exists + // if (dialect in listOf(TestDB.SQLITE)) { + // Assumptions.assumeFalse(true) + // } + + val db1 = dialect.connect() + try { + suspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE, db = db1) { + SchemaUtils.create(TestTable) + TestTable.deleteAll() + + repeat(testSize) { + TestTable.insert { + it[value] = 0 + } + } + } + + val entities = suspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE, db = db1) { + TestEntity + .find { TestTable.value eq 0 } + .toList() + } + exposedLogger.info("total entities {}", entities.size) + + List(entities.size) { index -> + async { + val statementInvocationNumber = AtomicInteger(0) + suspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE, db = db1) { + maxAttempts = 50 + + val entity = entities[index] + // R2DBC: entity was loaded in a different transaction — re-attach to + // the current transaction's cache before mutating (setValue is non-suspend). + TestEntity.attach(entity) + entity.value = 1 + + exposedLogger.info( + "Updating entity id={} invocation={} writeValuesSize={}", + entities[index].id, + statementInvocationNumber.incrementAndGet(), + entities[index].writeValues.size + ) + } + } + }.awaitAll() + + entities.forEach { + suspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE, db = db1) { + exposedLogger.info("DAO state after update: {} value={} writeValuesSize={}", it.id, it.value, it.writeValues.size) + } + } + + val db2 = dialect.connect() + + val notUpdated = suspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE, db = db2) { + TestTable + .selectAll() + .where { TestTable.value eq 0 } + .toList() + } + + notUpdated.forEach { + exposedLogger.info("not updated: {} value={}", it[TestTable.id], it[TestTable.value]) + } + + if (notUpdated.isNotEmpty()) { + error("Not all entries updated, wrong value for ${notUpdated.size}") + } + } finally { + suspendTransaction(db1) { + SchemaUtils.drop(TestTable) + } + } + } + + @Test + fun testEntityRestoresStateOnTransactionRestart() { + withConnection(dialect) { database, testDb -> + try { + val entity = suspendTransaction { + SchemaUtils.create(TestTable) + + TestEntity.new { value = 1 } + } + + suspendTransaction { + maxAttempts = 5 + + // R2DBC: an entity loaded in another transaction must be explicitly + // re-attached to the current transaction's cache before it can be mutated + // (setValue is non-suspend so it cannot auto-load like JDBC does). + TestEntity.attach(entity) + + assertEquals(1, entity.value) + entity.value += 1 + + throw SQLException("force transaction rollback and restart") + } + } catch (_: SQLException) { + // do nothing + } finally { + suspendTransaction { + SchemaUtils.drop(TestTable) + } + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityFieldWithTransformTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityFieldWithTransformTest.kt new file mode 100644 index 0000000000..c8b3dfa354 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityFieldWithTransformTest.kt @@ -0,0 +1,172 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import java.math.BigDecimal +import kotlin.random.Random +import kotlin.test.Test + +class R2dbcEntityFieldWithTransformTest : R2dbcDatabaseTestsBase() { + object TransformationsTable : IntIdTable() { + val value = varchar("value", 50) + } + + object NullableTransformationsTable : IntIdTable() { + val value = varchar("nullable", 50).nullable() + } + + class TransformationEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(TransformationsTable) + + var value by TransformationsTable.value.transform( + unwrap = { "transformed-$it" }, + wrap = { it.replace("transformed-", "") } + ) + } + + class NullableTransformationEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(NullableTransformationsTable) + + var value by NullableTransformationsTable.value.transform( + unwrap = { "transformed-$it" }, + wrap = { it?.replace("transformed-", "") } + ) + } + + @Test + fun testSetAndGetValue() { + withTables(TransformationsTable) { + val entity = TransformationEntity.new { + value = "stuff" + } + + assertEquals("stuff", entity.value) + + val row = TransformationsTable.selectAll() + .where(Op.TRUE) + .first() + + assertEquals("transformed-stuff", row[TransformationsTable.value]) + } + } + + @Test + fun testSetAndGetNullableValueWhilePresent() { + withTables(NullableTransformationsTable) { + val entity = NullableTransformationEntity.new { + value = "stuff" + } + + assertEquals("stuff", entity.value) + + val row = NullableTransformationsTable.selectAll() + .where(Op.TRUE) + .first() + + assertEquals("transformed-stuff", row[NullableTransformationsTable.value]) + } + } + + @Test + fun testSetAndGetNullableValueWhileAbsent() { + withTables(NullableTransformationsTable) { + val entity = NullableTransformationEntity.new {} + + assertEquals(null, entity.value) + + val row = NullableTransformationsTable.selectAll() + .where(Op.TRUE) + .first() + + assertEquals(null, row[NullableTransformationsTable.value]) + } + } + + object TableWithTransforms : IntIdTable() { + val value = varchar("value", 50) + .transform(wrap = { it.toBigDecimal() }, unwrap = { it.toString() }) + } + + class TableWithTransform(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(TableWithTransforms) + + var value by TableWithTransforms.value.transform(wrap = { it.toInt() }, unwrap = { it.toBigDecimal() }) + } + + @Test + fun testDaoTransformWithDslTransform() { + withTables(TableWithTransforms) { + TableWithTransform.new { + value = 10 + } + + // Correct DAO value + assertEquals(10, TableWithTransform.all().first().value) + + // Correct DSL value + assertEquals(BigDecimal(10), TableWithTransforms.selectAll().first()[TableWithTransforms.value]) + } + } + + class ChainedTransformationEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(TransformationsTable) + + var value by TransformationsTable.value + .transform( + unwrap = { "transformed-$it" }, + wrap = { it.replace("transformed-", "") } + ) + .transform( + unwrap = { if (it.length > 5) it.slice(0..4) else it }, + wrap = { it } + ) + } + + @Test + fun testChainedTransformation() { + withTables(TransformationsTable) { + ChainedTransformationEntity.new { + value = "qwertyuiop" + } + + assertEquals("qwert", ChainedTransformationEntity.all().first().value) + } + } + + class MemoizedChainedTransformationEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(TransformationsTable) + + var value by TransformationsTable.value + .transform( + unwrap = { "transformed-$it" }, + wrap = { it.replace("transformed-", "") } + ) + .memoizedTransform( + unwrap = { it + Random(10).nextInt(0, 100) }, + wrap = { it } + ) + } + + @Test + fun testMemoizedChainedTransformation() { + withTables(TransformationsTable) { + MemoizedChainedTransformationEntity.new { + value = "value#" + } + + val entity = MemoizedChainedTransformationEntity.all().first() + + val firstRead = entity.value + assertTrue(firstRead.startsWith("value#")) + assertEquals(firstRead, entity.value) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityHookTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityHookTest.kt new file mode 100644 index 0000000000..8443434466 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityHookTest.kt @@ -0,0 +1,374 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.r2dbc.dao.EntityChange +import org.jetbrains.exposed.r2dbc.dao.EntityChangeType +import org.jetbrains.exposed.r2dbc.dao.EntityHook +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.registeredChanges +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.toEntity +import org.jetbrains.exposed.r2dbc.dao.withHook +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import kotlin.test.Test + +object EntityHookTestData { + object Users : IntIdTable() { + val name = varchar("name", 50).index() + val age = integer("age") + } + + object Cities : IntIdTable() { + val name = varchar("name", 50) + val country = reference("country", Countries) + } + + object Countries : IntIdTable() { + val name = varchar("name", 50) + } + + object UsersToCities : Table() { + val user = reference("user", Users, onDelete = ReferenceOption.CASCADE) + val city = reference("city", Cities, onDelete = ReferenceOption.CASCADE) + } + + class User(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) + + var name by Users.name + var age by Users.age + val cities by City viaSuspend UsersToCities + } + + class City(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Cities) + + var name by Cities.name + val users by User viaSuspend UsersToCities + val country by Country referencedOnSuspend Cities.country + } + + class Country(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Countries) + + var name by Countries.name + val cities by City referrersOnSuspend Cities.country + } + + val allTables = arrayOf(Users, Cities, UsersToCities, Countries) +} + +class R2dbcEntityHookTest : R2dbcDatabaseTestsBase() { + private suspend fun trackChanges( + statement: suspend R2dbcTransaction.() -> T + ): Triple, String> { + val alreadyChanged = TransactionManager.current().registeredChanges().size + return suspendTransaction { + val result = statement() + flushCache() + Triple(result, registeredChanges().drop(alreadyChanged), transactionId) + } + } + + @Test + fun testCreated01() { + withTables(*EntityHookTestData.allTables) { + val (_, events, txId) = trackChanges { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + val x = EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + } + + assertEquals(2, events.count()) + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.City)?.name }, "St. Petersburg") + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.Country)?.name }, "RU") + events.forEach { + assertEquals(txId, it.transactionId) + } + } + } + + @Test + fun testDeleted01() { + withTables(*EntityHookTestData.allTables) { + val spbId = suspendTransaction { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + val x = EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + + flushCache() + x.id + } + + val (_, events, txId) = trackChanges { + val spb = EntityHookTestData.City.findById(spbId)!! + spb.delete() + } + + assertEquals(1, events.count()) + assertEquals(EntityChangeType.Removed, events.single().changeType) + assertEquals(spbId, events.single().entityId) + assertEquals(txId, events.single().transactionId) + } + } + + @Test + fun testModifiedSimple01() { + withTables(*EntityHookTestData.allTables) { + val (_, events1, _) = trackChanges { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + } + + assertEquals(2, events1.count()) + + val (_, events2, txId) = trackChanges { + val de = EntityHookTestData.Country.new { + name = "DE" + } + val x = EntityHookTestData.City.all().single() + x.name = "Munich" + x.country set de + } + // One may expect change for RU but we do not send it due to performance reasons + assertEquals(2, events2.count()) + assertEqualCollections(events2.mapNotNull { it.toEntity(EntityHookTestData.City)?.name }, "Munich") + assertEqualCollections(events2.mapNotNull { it.toEntity(EntityHookTestData.Country)?.name }, "DE") + events2.forEach { + assertEquals(txId, it.transactionId) + } + } + } + + @Test + fun testModifiedInnerTable01() { + withTables(*EntityHookTestData.allTables) { + suspendTransaction { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + val de = EntityHookTestData.Country.new { + name = "DE" + } + EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + EntityHookTestData.City.new { + name = "Munich" + country set de + } + EntityHookTestData.User.new { + name = "John" + age = 30 + } + + flushCache() + } + + val (_, events, txId) = trackChanges { + val spb = EntityHookTestData.City.find { EntityHookTestData.Cities.name eq "St. Petersburg" }.single() + val john = EntityHookTestData.User.all().single() + john.cities set listOf(spb) + } + + assertEquals(2, events.count()) + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.City)?.name }, "St. Petersburg") + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.User)?.name }, "John") + events.forEach { + assertEquals(txId, it.transactionId) + } + } + } + + @Test + fun testModifiedInnerTable02() { + withTables(*EntityHookTestData.allTables) { + suspendTransaction { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + val de = EntityHookTestData.Country.new { + name = "DE" + } + val spb = EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + val muc = EntityHookTestData.City.new { + name = "Munich" + country set de + } + val john = EntityHookTestData.User.new { + name = "John" + age = 30 + } + + john.cities set listOf(muc) + flushCache() + } + + val (_, events, txId) = trackChanges { + val spb = EntityHookTestData.City.find { EntityHookTestData.Cities.name eq "St. Petersburg" }.single() + val john = EntityHookTestData.User.all().single() + john.cities set listOf(spb) + } + + assertEquals(3, events.count()) + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.City)?.name }, "St. Petersburg", "Munich") + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.User)?.name }, "John") + events.forEach { + assertEquals(txId, it.transactionId) + } + } + } + + @Test + fun testModifiedInnerTable03() { + withTables(*EntityHookTestData.allTables) { + suspendTransaction { + val ru = EntityHookTestData.Country.new { + name = "RU" + } + val de = EntityHookTestData.Country.new { + name = "DE" + } + val spb = EntityHookTestData.City.new { + name = "St. Petersburg" + country set ru + } + val muc = EntityHookTestData.City.new { + name = "Munich" + country set de + } + val john = EntityHookTestData.User.new { + name = "John" + age = 30 + } + + john.cities set listOf(spb) + flushCache() + } + + val (_, events, txId) = trackChanges { + val john = EntityHookTestData.User.all().single() + john.cities set emptyList() + } + + assertEquals(2, events.count()) + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.City)?.name }, "St. Petersburg") + assertEqualCollections(events.mapNotNull { it.toEntity(EntityHookTestData.User)?.name }, "John") + events.forEach { + assertEquals(txId, it.transactionId) + } + } + } + + @Test + fun `single entity flush should trigger events`() { + withTables(EntityHookTestData.User.table) { + val (user, events, _) = trackChanges { + EntityHookTestData.User.new { + name = "John" + age = 30 + }.apply { flush() } + } + + assertEquals(1, events.size) + val createEvent = events.single() + assertEquals(user.id, createEvent.entityId) + assertEquals(EntityChangeType.Created, createEvent.changeType) + + val (_, events2, _) = trackChanges { + user.name = "Carl" + user.flush() + } + + assertEquals("Carl", user.name) + assertEquals(1, events2.size) + val updateEvent = events2.single() + assertEquals(user.id, updateEvent.entityId) + assertEquals(EntityChangeType.Updated, updateEvent.changeType) + } + } + + @Test + fun testCallingFlushNotifiesEntityHookSubscribers() { + withTables(EntityHookTestData.User.table) { + var hookCalls = 0 + val user = EntityHookTestData.User.new { + name = "1@test.local" + age = 30 + } + user.flush() + + EntityHook.subscribe { + hookCalls++ + } + + user.name = "2@test.local" + assertEquals(0, hookCalls) + + user.flush() + assertEquals(1, hookCalls) + + user.name = "3@test.local" + assertEquals(1, hookCalls) + + commit() + assertEquals(2, hookCalls) + } + } + + @Test + fun testWithHook() { + withTables(EntityHookTestData.User.table) { + var hookCalls = 0 + + withHook({ hookCalls++ }) { + val user = EntityHookTestData.User.new { + name = "name 1" + age = 25 + } + user.flush() + + user.name = "name 2" + } + + assertEquals(2, hookCalls) + + // Change value outside the 'withHook' + val user = EntityHookTestData.User.all().first() + user.name = "name 3" + user.flush() + + assertEquals(2, hookCalls) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt index c18c5d781e..2c4d11e98d 100644 --- a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityTests.kt @@ -14,13 +14,9 @@ import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException import org.jetbrains.exposed.r2dbc.dao.flushCache -import org.jetbrains.exposed.r2dbc.dao.relationships.backReferencedOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.load -import org.jetbrains.exposed.r2dbc.dao.relationships.optionalBackReferencedOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend -import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferrersOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend -import org.jetbrains.exposed.r2dbc.dao.relationships.referrersOnSuspend import org.jetbrains.exposed.r2dbc.dao.relationships.with import org.jetbrains.exposed.v1.core.Case import org.jetbrains.exposed.v1.core.Column diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityWithBlobTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityWithBlobTests.kt new file mode 100644 index 0000000000..eed181e7d0 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityWithBlobTests.kt @@ -0,0 +1,54 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.assertNull +import java.util.UUID +import kotlin.test.Test + +class R2dbcEntityWithBlobTests : R2dbcDatabaseTestsBase() { + + object BlobTable : IdTable("YTable") { + override val id: Column> = varchar("uuid", 36).entityId().clientDefault { + EntityID(UUID.randomUUID().toString(), EntityTestsData.YTable) + } + + val blob = blob("content").nullable() + + override val primaryKey = PrimaryKey(id) + } + + class BlobEntity(id: EntityID) : R2dbcEntity(id) { + var content by BlobTable.blob + + companion object : R2dbcEntityClass(BlobTable) + } + + @Test + fun testBlobField() { + withTables(BlobTable) { + val y1 = BlobEntity.new { + content = ExposedBlob("foo".toByteArray()) + } + + flushCache() + var y2 = BlobEntity.reload(y1)!! + assertEquals(String(y2.content!!.bytes), "foo") + + y2.content = null + flushCache() + y2 = BlobEntity.reload(y1)!! + assertNull(y2.content) + + y2.content = ExposedBlob("foo2".toByteArray()) + flushCache() + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcForeignIdEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcForeignIdEntityTest.kt new file mode 100644 index 0000000000..a675de6e2b --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcForeignIdEntityTest.kt @@ -0,0 +1,99 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertFalse +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class R2dbcForeignIdEntityTest : R2dbcDatabaseTestsBase() { + object Schema { + + object Projects : LongIdTable() { + val name = varchar("name", 50) + } + + object ProjectConfigs : IdTable() { + override val id = reference("id", Projects) + val setting = bool("setting") + } + + object Actors : IdTable("actors") { + override val id = varchar("guild_id", 13).entityId() + override val primaryKey = PrimaryKey(id) + } + + object Roles : IntIdTable("roles") { + val actor = reference("guild_id", Actors) + } + } + + class Project(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Schema.Projects) + + var name by Schema.Projects.name + } + + class ProjectConfig(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Schema.ProjectConfigs) + + var setting by Schema.ProjectConfigs.setting + } + + class Actor(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(Schema.Actors) + + val roles by Role referrersOnSuspend Schema.Roles.actor + } + + class Role(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Schema.Roles) + + val actor by Actor referencedOnSuspend Schema.Roles.actor + } + + @Test + fun foreignIdEntityUpdate() { + // reproducer for https://github.com/JetBrains/Exposed/issues/880 + withTables(Schema.Projects, Schema.ProjectConfigs, configure = { useNestedTransactions = true }) { + suspendTransaction { + // TODO we definitely need `newAndFlush()` alternative to the `new()` method + val projectId = Project.new { name = "Space" }.also { entityCache.flush() }.id.value + ProjectConfig.new(projectId) { setting = true } + } + + suspendTransaction { + ProjectConfig.all().first().setting = false + } + + suspendTransaction { + assertFalse(ProjectConfig.all().first().setting) + } + } + } + + @Test + fun testReferencedEntitiesWithIdenticalColumnNames() { + withTables(Schema.Actors, Schema.Roles) { + val actorA = Actor.new("3746529") { } + val roleA = Role.new { actor set actorA } + val roleB = Role.new { actor set actorA } + + assertContentEquals(listOf(roleA, roleB), actorA.roles().toList()) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcJavaUUIDTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcJavaUUIDTableEntityTest.kt new file mode 100644 index 0000000000..33ac2f312f --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcJavaUUIDTableEntityTest.kt @@ -0,0 +1,201 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.java.UUIDR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.java.UUIDR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable +import org.jetbrains.exposed.v1.core.java.javaUUID +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test +import java.util.UUID as JavaUUID + +class R2dbcJavaUUIDTableEntityTest : R2dbcDatabaseTestsBase() { + + @Suppress("MemberNameEqualsClassName") + object JavaUUIDTables { + object Cities : UUIDTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : UUIDR2dbcEntity(id) { + companion object : UUIDR2dbcEntityClass(Cities, null, null) + + var name by Cities.name + val towns by Town referrersOnSuspend Towns.cityId + } + + object People : UUIDTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UUIDR2dbcEntity(id) { + companion object : UUIDR2dbcEntityClass(People) + + var name by People.name + val city by City referencedOnSuspend People.cityId + } + + object Addresses : UUIDTable() { + val person = reference("person_id", People) + val city = reference("city_id", Cities) + val address = varchar("address", 255) + } + + class Address(id: EntityID) : UUIDR2dbcEntity(id) { + companion object : UUIDR2dbcEntityClass
(Addresses) + + val person by Person.referencedOnSuspend(Addresses.person) + val city by City.referencedOnSuspend(Addresses.city) + var address by Addresses.address + } + + object Towns : UUIDTable("towns") { + val cityId: Column = javaUUID("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UUIDR2dbcEntity(id) { + companion object : UUIDR2dbcEntityClass(Towns) + + val city by City referencedOnSuspend Towns.cityId + } + } + + @Test + fun `create tables`() { + withTables(JavaUUIDTables.Cities, JavaUUIDTables.People) { + assertEquals(true, JavaUUIDTables.Cities.exists()) + assertEquals(true, JavaUUIDTables.People.exists()) + } + } + + @Test + fun `create records`() { + withTables(JavaUUIDTables.Cities, JavaUUIDTables.People) { + val mumbai = JavaUUIDTables.City.new { name = "Mumbai" } + val pune = JavaUUIDTables.City.new { name = "Pune" } + JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "David D'souza" + city set mumbai + } + JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "Tushar Mumbaikar" + city set mumbai + } + JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "Tanu Arora" + city set pune + } + + val allCities = JavaUUIDTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(true, allCities.contains("Pune")) + assertEquals(false, allCities.contains("Chennai")) + + val allPeople = JavaUUIDTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("David D'souza", "Pune"))) + } + } + + @Test + fun `update and delete records`() { + withTables(JavaUUIDTables.Cities, JavaUUIDTables.People) { + val mumbai = JavaUUIDTables.City.new(JavaUUID.randomUUID()) { name = "Mumbai" } + val pune = JavaUUIDTables.City.new(JavaUUID.randomUUID()) { name = "Pune" } + JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "David D'souza" + city set mumbai + } + JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "Tushar Mumbaikar" + city set mumbai + } + val tanu = JavaUUIDTables.Person.new(JavaUUID.randomUUID()) { + name = "Tanu Arora" + city set pune + } + + tanu.delete() + pune.delete() + + val allCities = JavaUUIDTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(false, allCities.contains("Pune")) + + val allPeople = JavaUUIDTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("Tanu Arora", "Pune"))) + } + } + + @Test + fun `insert with inner table`() { + withTables(JavaUUIDTables.Addresses, JavaUUIDTables.Cities, JavaUUIDTables.People) { + val city1 = JavaUUIDTables.City.new { + name = "city1" + } + val person1 = JavaUUIDTables.Person.new { + name = "person1" + city set city1 + } + + val address1 = JavaUUIDTables.Address.new { + person set person1 + city set city1 + address = "address1" + } + + val address2 = JavaUUIDTables.Address.new { + person set person1 + city set city1 + address = "address2" + } + + address1.refresh(flush = true) + assertEquals("address1", address1.address) + + address2.refresh(flush = true) + assertEquals("address2", address2.address) + } + } + + @Test + fun testForeignKeyBetweenUUIDAndEntityIDColumns() { + withTables(JavaUUIDTables.Cities, JavaUUIDTables.Towns) { + val cId = JavaUUIDTables.Cities.insertAndGetId { + it[name] = "City A" + } + val tId = JavaUUIDTables.Towns.insertAndGetId { + it[cityId] = cId.value + } + + // lazy loaded referencedOn + val town1 = JavaUUIDTables.Town.all().single() + assertEquals(cId, town1.city().id) + + // eager loaded referencedOn + val town1WithCity = JavaUUIDTables.Town.all().with(JavaUUIDTables.Town::city).single() + assertEquals(cId, town1WithCity.city().id) + + // lazy loaded referrersOn + val city1 = JavaUUIDTables.City.all().single() + val towns = city1.towns() + assertEquals(cId, towns.first().city().id) + + // eager loaded referrersOn + val city1WithTowns = JavaUUIDTables.City.all().with(JavaUUIDTables.City::towns).single() + assertEquals(tId, city1WithTowns.towns().first().id) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcLongIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcLongIdTableEntityTest.kt new file mode 100644 index 0000000000..c92bd85339 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcLongIdTableEntityTest.kt @@ -0,0 +1,152 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test + +class R2dbcLongIdTableEntityTest : R2dbcDatabaseTestsBase() { + object LongIdTables { + object Cities : LongIdTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOnSuspend Towns.cityId + } + + object People : LongIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(People) + + var name by People.name + val city by City referencedOnSuspend People.cityId + } + + object Towns : LongIdTable("towns") { + val cityId: Column = long("city_id").references(Cities.id) + } + + class Town(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Towns) + + val city by City referencedOnSuspend Towns.cityId + } + } + + @Test + fun `create tables`() { + withTables(LongIdTables.Cities, LongIdTables.People) { + assertEquals(true, LongIdTables.Cities.exists()) + assertEquals(true, LongIdTables.People.exists()) + } + } + + @Test + fun `create records`() { + withTables(LongIdTables.Cities, LongIdTables.People) { + val mumbai = LongIdTables.City.new { name = "Mumbai" } + val pune = LongIdTables.City.new { name = "Pune" } + LongIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + LongIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + LongIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + val allCities = LongIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(true, allCities.contains("Pune")) + assertEquals(false, allCities.contains("Chennai")) + + val allPeople = LongIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("David D'souza", "Pune"))) + } + } + + @Test + fun `update and delete records`() { + withTables(LongIdTables.Cities, LongIdTables.People) { + val mumbai = LongIdTables.City.new { name = "Mumbai" } + val pune = LongIdTables.City.new { name = "Pune" } + LongIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + LongIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + val tanu = LongIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + tanu.delete() + pune.delete() + + val allCities = LongIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(false, allCities.contains("Pune")) + + val allPeople = LongIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("Tanu Arora", "Pune"))) + } + } + + @Test + fun testForeignKeyBetweenLongAndEntityIDColumns() { + withTables(LongIdTables.Cities, LongIdTables.Towns) { + val cId = LongIdTables.Cities.insertAndGetId { + it[name] = "City A" + } + val tId = LongIdTables.Towns.insertAndGetId { + it[cityId] = cId.value + } + + // lazy loaded referencedOn + val town1 = LongIdTables.Town.all().single() + assertEquals(cId, town1.city().id) + + // eager loaded referencedOn + val town1WithCity = LongIdTables.Town.all().with(LongIdTables.Town::city).single() + assertEquals(cId, town1WithCity.city().id) + + // lazy loaded referrersOn + val city1 = LongIdTables.City.all().single() + val towns = city1.towns() + assertEquals(cId, towns.first().city().id) + + // eager loaded referrersOn + val city1WithTowns = LongIdTables.City.all().with(LongIdTables.City::towns).single() + assertEquals(tId, city1WithTowns.towns().first().id) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcNonAutoIncEntities.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcNonAutoIncEntities.kt new file mode 100644 index 0000000000..fc4128a210 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcNonAutoIncEntities.kt @@ -0,0 +1,134 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.any +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.update +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test + +class R2dbcNonAutoIncEntities : R2dbcDatabaseTestsBase() { + abstract class BaseNonAutoIncTable(name: String) : IdTable(name) { + override val id = integer("id").entityId() + val b1 = bool("b1") + } + + object NotAutoIntIdTable : BaseNonAutoIncTable("") { + val defaultedInt = integer("i1") + } + + class NotAutoEntity(id: EntityID) : R2dbcEntity(id) { + var b1 by NotAutoIntIdTable.b1 + var defaultedInNew by NotAutoIntIdTable.defaultedInt + + companion object : R2dbcEntityClass(NotAutoIntIdTable) { + val lastId = AtomicInteger(0) + internal const val defaultInt = 42 + fun new(b: Boolean) = new(lastId.incrementAndGet()) { b1 = b } + + override fun new(id: Int?, init: NotAutoEntity.() -> Unit): NotAutoEntity { + return super.new(id ?: lastId.incrementAndGet()) { + defaultedInNew = defaultInt + init() + } + } + } + } + + @Test + fun testDefaultsWithOverrideNew() { + withTables(NotAutoIntIdTable) { + val entity1 = NotAutoEntity.new(true) + assertEquals(true, entity1.b1) + assertEquals(NotAutoEntity.defaultInt, entity1.defaultedInNew) + + val entity2 = NotAutoEntity.new { + b1 = false + defaultedInNew = 1 + } + assertEquals(false, entity2.b1) + assertEquals(1, entity2.defaultedInNew) + } + } + + @Test + fun testNotAutoIncTable() { + withTables(NotAutoIntIdTable) { + val e1 = NotAutoEntity.new(true) + val e2 = NotAutoEntity.new(false) + + TransactionManager.current().flushCache() + + val all = NotAutoEntity.all() + assert(all.any { it.id == e1.id }) + assert(all.any { it.id == e2.id }) + } + } + + object CustomPrimaryKeyColumnTable : IdTable() { + val customId: Column = varchar("customId", 256) + override val primaryKey = PrimaryKey(customId) + override val id: Column> = customId.entityId() + } + + class CustomPrimaryKeyColumnEntity(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(CustomPrimaryKeyColumnTable) + + var customId by CustomPrimaryKeyColumnTable.customId + } + + @Test + fun testIdValueIsTheSameAsCustomPrimaryKeyColumn() { + withTables(CustomPrimaryKeyColumnTable) { + val request = CustomPrimaryKeyColumnEntity.new { + customId = "customIdValue" + } + flushCache() + + assertEquals("customIdValue", request.id.value) + } + } + + object RequestsTable : IdTable() { + val requestId = varchar("request_id", 256) + val deleted = bool("deleted") + override val primaryKey: PrimaryKey = PrimaryKey(requestId) + override val id: Column> = requestId.entityId() + } + + class Request(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(RequestsTable) + + var requestId by RequestsTable.requestId + var deleted by RequestsTable.deleted + + override suspend fun delete() { + RequestsTable.update({ RequestsTable.id eq id }) { + it[deleted] = true + } + } + } + + @Test + fun testAccessEntityIdFromOverrideEntityMethod() { + withTables(RequestsTable) { + val request = Request.new { + requestId = "test1" + deleted = false + } + + request.delete() + + val updated = Request["test1"] + assertEquals(true, updated.deleted) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcOrderedReferenceTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcOrderedReferenceTest.kt new file mode 100644 index 0000000000..60faf157ec --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcOrderedReferenceTest.kt @@ -0,0 +1,249 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.statements.StatementContext +import org.jetbrains.exposed.v1.core.statements.StatementInterceptor +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import kotlin.math.max +import kotlin.test.Test +import kotlin.test.assertNotNull + +class R2dbcOrderedReferenceTest : R2dbcDatabaseTestsBase() { + object Users : IntIdTable() + + object UserRatings : IntIdTable() { + val value = integer("value") + val user = reference("user", Users) + } + + object UserNullableRatings : IntIdTable() { + val value = integer("value") + val user = reference("user", Users).nullable() + } + + class UserRatingDefaultOrder(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserDefaultOrder referencedOnSuspend UserRatings.user + } + + class UserNullableRatingDefaultOrder(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(UserNullableRatings) + + var value by UserNullableRatings.value + val user by UserDefaultOrder optionalReferencedOnSuspend UserNullableRatings.user + } + + class UserDefaultOrder(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) + + val ratings by UserRatingDefaultOrder referrersOnSuspend UserRatings.user orderBy UserRatings.value + val nullableRatings by UserNullableRatingDefaultOrder optionalReferrersOnSuspend UserNullableRatings.user orderBy UserNullableRatings.value + } + + @Test + fun testDefaultOrder() { + withOrderedReferenceTestTables { + val user = UserDefaultOrder.all().first() + + unsortedRatingValues.sorted().toList().zip(user.ratings().toList()).forEach { (value, rating) -> + assertEquals(value, rating.value) + } + unsortedRatingValues.sorted().zip(user.nullableRatings().toList()).forEach { (value, rating) -> + assertEquals(value, rating.value) + } + } + } + + @Test + fun testNoDuplicatedOrderByPartsInQuery() { + // This interceptor counts duplicated ORDER BY parts in the sql sent to database. + // We want to be sure that DAO doesn't create duplicated parts. + val interceptor = object : StatementInterceptor { + var maxDuplicates = 0 + override fun beforeExecution(transaction: Transaction, context: StatementContext) { + val duplicatedPartsAmount = context.statement.prepareSQL(transaction) + // Get all the parts from order by section + .lowercase() + .substringAfter("order by") + .split(",") + .map { it.trim() } + // Count the occurrences of each part and take maximum + .groupBy { it } + .mapValues { (_, list) -> list.size } + .maxByOrNull { it.value } + ?.value ?: 0 + + maxDuplicates = max(maxDuplicates, duplicatedPartsAmount) + } + } + + withOrderedReferenceTestTables { + registerInterceptor(interceptor) + // `orderBy` on references in DAO Entity classes could collect duplicated parts. + // That method is executed on every access to the field, so every query has + // one more duplicated part + // It's mentioned in the original issue + // 'EXPOSED-950 Order by clause is repeated hundredfold' + repeat(5) { + val user = UserDefaultOrder.all().first() + entityCache.clear() + + // This sections needs only to force DAO fetch the data to execute SQL queries + user.ratings().forEach { rating -> + assertNotNull(rating.value) + } + + assertEquals(1, interceptor.maxDuplicates) + } + } + } + + class UserRatingMultiColumn(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserMultiColumn referencedOnSuspend UserRatings.user + } + + class UserNullableRatingMultiColumn(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(UserNullableRatings) + + var value by UserNullableRatings.value + val user by UserMultiColumn optionalReferencedOnSuspend UserNullableRatings.user + } + + class UserMultiColumn(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) + + val ratings by UserRatingMultiColumn + .referrersOnSuspend(UserRatings.user) + .orderBy(UserRatings.value to SortOrder.DESC, UserRatings.id to SortOrder.DESC) + val nullableRatings by UserNullableRatingMultiColumn + .optionalReferrersOnSuspend(UserNullableRatings.user) + .orderBy( + UserNullableRatings.value to SortOrder.DESC, + UserNullableRatings.id to SortOrder.DESC + ) + } + + @Test + fun testMultiColumnOrder() { + withOrderedReferenceTestTables { + val ratings = UserMultiColumn.all().first().ratings().toList() + val nullableRatings = UserMultiColumn.all().first().nullableRatings().toList() + + // Ensure each value is less than the one before it. + // IDs should be sorted within groups of identical values. + fun assertRatingsOrdered(current: UserRatingMultiColumn, prev: UserRatingMultiColumn) { + assertTrue(current.value <= prev.value) + if (current.value == prev.value) { + assertTrue(current.id.value <= prev.id.value) + } + } + + fun assertNullableRatingsOrdered(current: UserNullableRatingMultiColumn, prev: UserNullableRatingMultiColumn) { + assertTrue(current.value <= prev.value) + if (current.value == prev.value) { + assertTrue(current.id.value <= prev.id.value) + } + } + + for (i in 1..) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserChainedColumn referencedOnSuspend UserRatings.user + } + + class UserChainedColumn(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Users) + + val ratings by UserRatingChainedColumn referrersOnSuspend UserRatings.user orderBy + (UserRatings.value to SortOrder.DESC) orderBy (UserRatings.id to SortOrder.DESC) + } + + @Test + fun testChainedOrderBy() { + withOrderedReferenceTestTables { + val ratings = UserChainedColumn.all().first().ratings().toList() + + fun assertRatingsOrdered(current: UserRatingChainedColumn, prev: UserRatingChainedColumn) { + assertTrue(current.value <= prev.value) + if (current.value == prev.value) { + assertTrue(current.id.value <= prev.id.value) + } + } + + for (i in 1.. Unit) { + withTables(Users, UserRatings, UserNullableRatings) { db -> + val userId = Users.insertAndGetId { } + unsortedRatingValues.forEach { value -> + UserRatings.insert { + it[user] = userId + it[UserRatings.value] = value + } + UserNullableRatings.insert { + it[user] = userId + it[UserRatings.value] = value + } + UserNullableRatings.insert { + it[user] = null + it[UserRatings.value] = value + } + } + statement(db) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcSelfReferenceTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcSelfReferenceTest.kt new file mode 100644 index 0000000000..36315f3281 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcSelfReferenceTest.kt @@ -0,0 +1,105 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.sql.tests.shared.dml.DMLTestsData +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class R2dbcSelfReferenceTest { + + @Test + fun simpleTest() { + assertEqualLists(listOf(DMLTestsData.Cities), SchemaUtils.sortTablesByReferences(listOf(DMLTestsData.Cities))) + assertEqualLists(listOf(DMLTestsData.Cities, DMLTestsData.Users), SchemaUtils.sortTablesByReferences(listOf(DMLTestsData.Users))) + + val rightOrder = listOf(DMLTestsData.Cities, DMLTestsData.Users, DMLTestsData.UserData) + val r1 = SchemaUtils.sortTablesByReferences(listOf(DMLTestsData.Cities, DMLTestsData.UserData, DMLTestsData.Users)) + val r2 = SchemaUtils.sortTablesByReferences(listOf(DMLTestsData.UserData, DMLTestsData.Cities, DMLTestsData.Users)) + val r3 = SchemaUtils.sortTablesByReferences(listOf(DMLTestsData.Users, DMLTestsData.Cities, DMLTestsData.UserData)) + assertEqualLists(rightOrder, r1) + assertEqualLists(rightOrder, r2) + assertEqualLists(rightOrder, r3) + } + + object TestTables { + object cities : Table() { + val id = integer("id").autoIncrement() + val name = varchar("name", 50) + val strange_id = varchar("strange_id", 10).references(strangeTable.id) + + override val primaryKey = PrimaryKey(id) + } + + object users : Table() { + val id = varchar("id", 10) + val name = varchar("name", length = 50) + val cityId = (integer("city_id") references cities.id).nullable() + + override val primaryKey = PrimaryKey(id) + } + + object noRefereeTable : Table() { + val id = varchar("id", 10) + val col1 = varchar("col1", 10) + + override val primaryKey = PrimaryKey(id) + } + + object refereeTable : Table() { + val id = varchar("id", 10) + val ref = reference("ref", noRefereeTable.id) + + override val primaryKey = PrimaryKey(id) + } + + object referencedTable : IntIdTable() { + val col3 = varchar("col3", 10) + } + + object strangeTable : Table() { + val id = varchar("id", 10) + val user_id = varchar("user_id", 10) references users.id + val comment = varchar("comment", 30) + val value = integer("value") + + override val primaryKey = PrimaryKey(id) + } + } + + @Test + fun cycleReferencesCheckTest() { + val original = listOf( + TestTables.cities, + TestTables.users, + TestTables.strangeTable, + TestTables.noRefereeTable, + TestTables.refereeTable, + TestTables.referencedTable + ) + val sortedTables = SchemaUtils.sortTablesByReferences(original) + val expected = listOf( + TestTables.users, + TestTables.strangeTable, + TestTables.cities, + TestTables.noRefereeTable, + TestTables.refereeTable, + TestTables.referencedTable + ) + + assertEqualLists(expected, sortedTables) + } + + @Test + fun testHasCycle() { + assertFalse(SchemaUtils.checkCycle(TestTables.referencedTable)) + assertFalse(SchemaUtils.checkCycle(TestTables.refereeTable)) + assertFalse(SchemaUtils.checkCycle(TestTables.noRefereeTable)) + assertTrue(SchemaUtils.checkCycle(TestTables.users)) + assertTrue(SchemaUtils.checkCycle(TestTables.cities)) + assertTrue(SchemaUtils.checkCycle(TestTables.strangeTable)) + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt new file mode 100644 index 0000000000..d37cbd7ed0 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt @@ -0,0 +1,153 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.UIntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.UIntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.UIntIdTable +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test + +class R2dbcUIntIdTableEntityTest : R2dbcDatabaseTestsBase() { + + @Test + fun `create tables`() { + withTables(UIntIdTables.Cities, UIntIdTables.People) { + assertEquals(true, UIntIdTables.Cities.exists()) + assertEquals(true, UIntIdTables.People.exists()) + } + } + + @Test + fun `create records`() { + withTables(UIntIdTables.Cities, UIntIdTables.People) { + val mumbai = UIntIdTables.City.new { name = "Mumbai" } + val pune = UIntIdTables.City.new { name = "Pune" } + UIntIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + UIntIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + UIntIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + val allCities = UIntIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(true, allCities.contains("Pune")) + assertEquals(false, allCities.contains("Chennai")) + + val allPeople = UIntIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("David D'souza", "Pune"))) + } + } + + @Test + fun `update and delete records`() { + withTables(UIntIdTables.Cities, UIntIdTables.People) { + val mumbai = UIntIdTables.City.new { name = "Mumbai" } + val pune = UIntIdTables.City.new { name = "Pune" } + UIntIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + UIntIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + val tanu = UIntIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + tanu.delete() + pune.delete() + + val allCities = UIntIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(false, allCities.contains("Pune")) + + val allPeople = UIntIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("Tanu Arora", "Pune"))) + } + } + + @Test + fun testForeignKeyBetweenUIntAndEntityIDColumns() { + withTables(UIntIdTables.Cities, UIntIdTables.Towns) { + val cId = UIntIdTables.Cities.insertAndGetId { + it[name] = "City A" + } + val tId = UIntIdTables.Towns.insertAndGetId { + it[cityId] = cId.value + } + + // lazy loaded referencedOn + val town1 = UIntIdTables.Town.all().single() + assertEquals(cId, town1.city().id) + + // eager loaded referencedOn + val town1WithCity = UIntIdTables.Town.all().with(UIntIdTables.Town::city).single() + assertEquals(cId, town1WithCity.city().id) + + // lazy loaded referrersOn + val city1 = UIntIdTables.City.all().single() + val towns = city1.towns() + assertEquals(cId, towns.first().city().id) + + // eager loaded referrersOn + val city1WithTowns = UIntIdTables.City.all().with(UIntIdTables.City::towns).single() + assertEquals(tId, city1WithTowns.towns().first().id) + } + } +} + +object UIntIdTables { + object Cities : UIntIdTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : UIntR2dbcEntity(id) { + companion object : UIntR2dbcEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOnSuspend Towns.cityId + } + + object People : UIntIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UIntR2dbcEntity(id) { + companion object : UIntR2dbcEntityClass(People) + + var name by People.name + val city by City referencedOnSuspend People.cityId + } + + object Towns : UIntIdTable("towns") { + val cityId: Column = uinteger("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UIntR2dbcEntity(id) { + companion object : UIntR2dbcEntityClass(Towns) + + val city by City referencedOnSuspend Towns.cityId + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt new file mode 100644 index 0000000000..1e5bd56764 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt @@ -0,0 +1,157 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.ULongR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.ULongR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.ULongIdTable +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.Test + +class R2dbcULongIdTableEntityTest : R2dbcDatabaseTestsBase() { + + @Test + fun `create tables`() { + withTables(ULongIdTables.People) { + assertEquals(true, ULongIdTables.Cities.exists()) + assertEquals(true, ULongIdTables.People.exists()) + } + } + + @Test + fun `create records`() { + withTables(ULongIdTables.People) { + val mumbai = ULongIdTables.City.new { name = "Mumbai" } + val pune = ULongIdTables.City.new { name = "Pune" } + ULongIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + ULongIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + ULongIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + val allCities = ULongIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(true, allCities.contains("Pune")) + assertEquals(false, allCities.contains("Chennai")) + + val allPeople = ULongIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("David D'souza", "Pune"))) + } + } + + @Test + fun `update and delete records`() { + withTables(ULongIdTables.People) { + val mumbai = ULongIdTables.City.new { name = "Mumbai" } + val pune = ULongIdTables.City.new { name = "Pune" } + ULongIdTables.Person.new { + name = "David D'souza" + city set mumbai + } + ULongIdTables.Person.new { + name = "Tushar Mumbaikar" + city set mumbai + } + val tanu = ULongIdTables.Person.new { + name = "Tanu Arora" + city set pune + } + + tanu.delete() + pune.delete() + + val allCities = ULongIdTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(false, allCities.contains("Pune")) + + val allPeople = ULongIdTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("Tanu Arora", "Pune"))) + } + } + + @Test + fun testForeignKeyBetweenULongAndEntityIDColumns() { + withTables(ULongIdTables.Cities, ULongIdTables.Towns) { + val cId = ULongIdTables.Cities.insertAndGetId { + it[name] = "City A" + } + val tId = ULongIdTables.Towns.insertAndGetId { + it[cityId] = cId.value + } + + // lazy loaded referencedOn + val town1 = ULongIdTables.Town.all().single() + assertEquals(cId, town1.city().id) + + // eager loaded referencedOn + val town1WithCity = + ULongIdTables.Town.all().with(ULongIdTables.Town::city) + .single() + assertEquals(cId, town1WithCity.city().id) + + // lazy loaded referrersOn + val city1 = ULongIdTables.City.all().single() + val towns = city1.towns() + assertEquals(cId, towns.first().city().id) + + // eager loaded referrersOn + val city1WithTowns = + ULongIdTables.City.all().with(ULongIdTables.City::towns) + .single() + assertEquals(tId, city1WithTowns.towns().first().id) + } + } +} + +object ULongIdTables { + object Cities : ULongIdTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : ULongR2dbcEntity(id) { + companion object : ULongR2dbcEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOnSuspend Towns.cityId + } + + object People : ULongIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : ULongR2dbcEntity(id) { + companion object : ULongR2dbcEntityClass(People) + + var name by People.name + val city by City referencedOnSuspend People.cityId + } + + object Towns : ULongIdTable("towns") { + val cityId: Column = ulong("city_id").references(Cities.id) + } + + class Town(id: EntityID) : ULongR2dbcEntity(id) { + companion object : ULongR2dbcEntityClass(Towns) + + val city by City referencedOnSuspend Towns.cityId + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUuidTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUuidTableEntityTest.kt new file mode 100644 index 0000000000..47bea36cf1 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcUuidTableEntityTest.kt @@ -0,0 +1,248 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.UuidTable +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import org.jetbrains.exposed.v1.r2dbc.tests.versionNumber +import kotlin.test.Test +import kotlin.uuid.Uuid + +class R2dbcUuidTableEntityTest : R2dbcDatabaseTestsBase() { + @Suppress("MemberNameEqualsClassName") + object UuidTables { + object Cities : UuidTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOnSuspend Towns.cityId + } + + object People : UuidTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass(People) + + var name by People.name + val city by City referencedOnSuspend People.cityId + } + + object Addresses : UuidTable() { + val person = reference("person_id", People) + val city = reference("city_id", Cities) + val address = varchar("address", 255) + } + + class Address(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass
(Addresses) + + val person by Person.referencedOnSuspend(Addresses.person) + val city by City.referencedOnSuspend(Addresses.city) + var address by Addresses.address + } + + object Towns : UuidTable("towns") { + val cityId: Column = uuid("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass(Towns) + + val city by City referencedOnSuspend Towns.cityId + } + + object Books : UuidTable(uuidVersion = UuidVersion.V7) { // id should use V7 + val title = varchar("title", 256) + val ssid = uuid("ssid").autoGenerate() // should use V4 + val pubId = uuid("pub_id").autoGenerate(UuidVersion.V7) // should use V7 + val pubCityId = reference("pub_city_id", Cities) // should use V4 as Cities uses V4 + } + + class Book(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass(Books) + + var title by Books.title + var ssid by Books.ssid + var pubId by Books.pubId + val pubCity by City referencedOnSuspend Books.pubCityId + } + } + + @Test + fun `create tables`() { + withTables(UuidTables.Cities, UuidTables.People) { + assertEquals(true, UuidTables.Cities.exists()) + assertEquals(true, UuidTables.People.exists()) + } + } + + @Test + fun `create records`() { + withTables(UuidTables.Cities, UuidTables.People) { + val mumbai = UuidTables.City.new { name = "Mumbai" } + val pune = UuidTables.City.new { name = "Pune" } + UuidTables.Person.new(Uuid.random()) { + name = "David D'souza" + city set mumbai + } + UuidTables.Person.new(Uuid.random()) { + name = "Tushar Mumbaikar" + city set mumbai + } + UuidTables.Person.new(Uuid.random()) { + name = "Tanu Arora" + city set pune + } + + val allCities = UuidTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(true, allCities.contains("Pune")) + assertEquals(false, allCities.contains("Chennai")) + + val allPeople = UuidTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("David D'souza", "Pune"))) + } + } + + @Test + fun `update and delete records`() { + withTables(UuidTables.Cities, UuidTables.People) { + val mumbai = UuidTables.City.new(Uuid.random()) { name = "Mumbai" } + val pune = UuidTables.City.new(Uuid.random()) { name = "Pune" } + UuidTables.Person.new(Uuid.random()) { + name = "David D'souza" + city set mumbai + } + UuidTables.Person.new(Uuid.random()) { + name = "Tushar Mumbaikar" + city set mumbai + } + val tanu = UuidTables.Person.new(Uuid.random()) { + name = "Tanu Arora" + city set pune + } + + tanu.delete() + pune.delete() + + val allCities = UuidTables.City.all().map { it.name }.toList() + assertEquals(true, allCities.contains("Mumbai")) + assertEquals(false, allCities.contains("Pune")) + + val allPeople = UuidTables.Person.all().map { Pair(it.name, it.city().name) }.toList() + assertEquals(true, allPeople.contains(Pair("David D'souza", "Mumbai"))) + assertEquals(false, allPeople.contains(Pair("Tanu Arora", "Pune"))) + } + } + + @Test + fun `insert with inner table`() { + withTables(UuidTables.Addresses, UuidTables.Cities, UuidTables.People) { + val city1 = UuidTables.City.new { + name = "city1" + } + val person1 = UuidTables.Person.new { + name = "person1" + city set city1 + } + + val address1 = UuidTables.Address.new { + person set person1 + city set city1 + address = "address1" + } + + val address2 = UuidTables.Address.new { + person set person1 + city set city1 + address = "address2" + } + + address1.refresh(flush = true) + assertEquals("address1", address1.address) + + address2.refresh(flush = true) + assertEquals("address2", address2.address) + } + } + + @Test + fun testForeignKeyBetweenUuidAndEntityIDColumns() { + withTables(UuidTables.Cities, UuidTables.Towns) { + val cId = UuidTables.Cities.insertAndGetId { + it[name] = "City A" + } + val tId = UuidTables.Towns.insertAndGetId { + it[cityId] = cId.value + } + + // lazy loaded referencedOn + val town1 = UuidTables.Town.all().single() + assertEquals(cId, town1.city().id) + + // eager loaded referencedOn + val town1WithCity = UuidTables.Town.all().with(UuidTables.Town::city).single() + assertEquals(cId, town1WithCity.city().id) + + // lazy loaded referrersOn + val city1 = UuidTables.City.all().single() + val towns = city1.towns() + assertEquals(cId, towns.first().city().id) + + // eager loaded referrersOn + val city1WithTowns = UuidTables.City.all().with(UuidTables.City::towns).single() + assertEquals(tId, city1WithTowns.towns().first().id) + } + } + + @Test + fun testUuidVersionAutoGenerated() { + withTables(UuidTables.Cities, UuidTables.Books) { + // generateV4() used by default if UuidTable primary constructor used + val munich = UuidTables.City.new { name = "Munich" } + assertEquals(4, munich.id.value.versionNumber()) + + // UuidTable secondary constructor used with generateV7() enabled + val book1 = UuidTables.Book.new { + title = "Joy of Kotlin" + pubCity set munich + } + // so only the UuidTable.id should automatically use V7 now + assertEquals(7, book1.id.value.versionNumber()) + // other Uuid columns detected in the table should use whatever version they are defined to use + assertEquals(4, book1.ssid.versionNumber()) + assertEquals(7, book1.pubId.versionNumber()) + assertEquals(4, book1.pubCity().id.value.versionNumber()) + + Thread.sleep(100) + + val book2 = UuidTables.Book.new { + title = "Kotlin in Action" + pubCity set munich + } + assertEquals(7, book2.id.value.versionNumber()) + // time-based Uuids are strictly ordered + assertTrue(book1.id.value < book2.id.value) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcViaTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcViaTest.kt new file mode 100644 index 0000000000..2c17dd04ae --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcViaTest.kt @@ -0,0 +1,399 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.CompositeR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.CompositeR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcInnerTableLinkAccessor +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.dao.id.UuidTable +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import java.util.Objects +import kotlin.reflect.jvm.isAccessible +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.uuid.Uuid + +object ViaTestData { + object NumbersTable : UuidTable() { + val number = integer("number") + } + + object StringsTable : IdTable("") { + override val id: Column> = long("id").autoIncrement().entityId() + val text = varchar("text", 10) + + override val primaryKey = PrimaryKey(id) + } + + interface IConnectionTable { + val numId: Column> + val stringId: Column> + } + + object ConnectionTable : Table(), IConnectionTable { + override val numId = reference("numId", NumbersTable, ReferenceOption.CASCADE) + override val stringId = reference("stringId", StringsTable, ReferenceOption.CASCADE) + + init { + index(true, numId, stringId) + } + } + + object ConnectionAutoIncTable : IntIdTable(), IConnectionTable { + override val numId = reference("numId", NumbersTable, ReferenceOption.CASCADE) + override val stringId = reference("stringId", StringsTable, ReferenceOption.CASCADE) + + init { + index(true, numId, stringId) + } + } + + val allTables: Array = arrayOf(NumbersTable, StringsTable, ConnectionTable, ConnectionAutoIncTable) +} + +class VNumber(id: EntityID) : UuidR2dbcEntity(id) { + var number by ViaTestData.NumbersTable.number + val connectedStrings by VString viaSuspend ViaTestData.ConnectionTable + val connectedAutoStrings by VString viaSuspend ViaTestData.ConnectionAutoIncTable + + companion object : UuidR2dbcEntityClass(ViaTestData.NumbersTable) +} + +class VString(id: EntityID) : R2dbcEntity(id) { + var text by ViaTestData.StringsTable.text + + companion object : R2dbcEntityClass(ViaTestData.StringsTable) +} + +class R2dbcViaTest : R2dbcDatabaseTestsBase() { + private suspend fun VNumber.testWithBothTables(valuesToSet: List, body: suspend (ViaTestData.IConnectionTable, List) -> Unit) { + listOf(ViaTestData.ConnectionTable, ViaTestData.ConnectionAutoIncTable).forEach { t -> + if (t == ViaTestData.ConnectionTable) { + connectedStrings set valuesToSet + } else { + connectedAutoStrings set valuesToSet + } + + val result = t.selectAll().toList() + body(t, result) + } + } + + @Test + fun testConnection01() { + withTables(*ViaTestData.allTables) { + val n = VNumber.new { number = 10 } + val s = VString.new { text = "aaa" } + n.testWithBothTables(listOf(s)) { table, result -> + val row = result.single() + assertEquals(n.id, row[table.numId]) + assertEquals(s.id, row[table.stringId]) + } + } + } + + @Test + fun testConnection02() { + withTables(*ViaTestData.allTables) { + val n1 = VNumber.new { number = 1 } + val n2 = VNumber.new { number = 2 } + val s1 = VString.new { text = "aaa" } + val s2 = VString.new { text = "bbb" } + + n1.testWithBothTables(listOf(s1, s2)) { table, row -> + assertEquals(2, row.count()) + assertEquals(n1.id, row[0][table.numId]) + assertEquals(n1.id, row[1][table.numId]) + assertEqualCollections(listOf(s1.id, s2.id), row.map { it[table.stringId] }) + } + } + } + + @Test + fun testConnection03() { + withTables(*ViaTestData.allTables) { + val n1 = VNumber.new { number = 1 } + val n2 = VNumber.new { number = 2 } + val s1 = VString.new { text = "aaa" } + val s2 = VString.new { text = "bbb" } + + n1.testWithBothTables(listOf(s1, s2)) { _, _ -> } + n2.testWithBothTables(listOf(s1, s2)) { _, row -> + assertEquals(4, row.count()) + assertEqualCollections(n1.connectedStrings(), listOf(s1, s2)) + assertEqualCollections(n2.connectedStrings(), listOf(s1, s2)) + } + + n1.testWithBothTables(emptyList()) { table, row -> + assertEquals(2, row.count()) + assertEquals(n2.id, row[0][table.numId]) + assertEquals(n2.id, row[1][table.numId]) + assertEqualCollections(n1.connectedStrings(), emptyList()) + assertEqualCollections(n2.connectedStrings(), listOf(s1, s2)) + } + } + } + + @Test + fun testConnection04() { + withTables(*ViaTestData.allTables) { + val n1 = VNumber.new { number = 1 } + val n2 = VNumber.new { number = 2 } + val s1 = VString.new { text = "aaa" } + val s2 = VString.new { text = "bbb" } + + n1.testWithBothTables(listOf(s1, s2)) { _, _ -> } + n2.testWithBothTables(listOf(s1, s2)) { _, row -> + assertEquals(4, row.count()) + assertEqualCollections(n1.connectedStrings(), listOf(s1, s2)) + assertEqualCollections(n2.connectedStrings(), listOf(s1, s2)) + } + + n1.testWithBothTables(listOf(s1)) { _, row -> + assertEquals(3, row.count()) + assertEqualCollections(n1.connectedStrings(), listOf(s1)) + assertEqualCollections(n2.connectedStrings(), listOf(s1, s2)) + } + } + } + + object NodesTable : IntIdTable() { + val name = varchar("name", 50) + } + + object NodeToNodes : Table() { + val parent = reference("parent_node_id", NodesTable) + val child = reference("child_user_id", NodesTable) + } + + class Node(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(NodesTable) + + var name by NodesTable.name + val parents by Node.viaSuspend(NodeToNodes.child, NodeToNodes.parent) + val children by Node.viaSuspend(NodeToNodes.parent, NodeToNodes.child) + + override fun equals(other: Any?): Boolean = (other as? Node)?.id == id + + override fun hashCode(): Int = Objects.hash(id) + } + + @Test + fun testHierarchicalReferences() { + withTables(NodesTable, NodeToNodes) { + val root = Node.new { name = "root" } + val child1 = Node.new { + name = "child1" + } + // TODO at the current moment it's not possible to set this value inside `new()`, becuase `new(){}` block is non suspend + child1.parents set listOf(root) + + assertEquals(0L, root.parents().count()) + assertEquals(1L, root.children().count()) + + val child2 = Node.new { name = "child2" } + root.children set listOf(child1, child2) + + assertEquals(root, child1.parents().singleOrNull()) + assertEquals(root, child2.parents().singleOrNull()) + } + } + + @Test + fun testRefresh() { + withTables(*ViaTestData.allTables) { + val s = VString.new { text = "ccc" }.apply { + refresh(true) + } + assertEquals("ccc", s.text) + } + } + + @Test + fun testWarmUpOnHierarchicalEntities() { + withTables(NodesTable, NodeToNodes) { + val child1 = Node.new { name = "child1" } + val child2 = Node.new { name = "child1" } + val root1 = Node.new { + name = "root1" + } + // TODO same problem with `new(){}` block + root1.children set listOf(child1) + + val root2 = Node.new { + name = "root2" + } + // TODO same problem with `new(){}` block + root2.children set listOf(child1, child2) + + entityCache.clear(flush = true) + + suspend fun checkChildrenReferences(node: Node, values: List) { + val sourceColumn = (Node::children.apply { isAccessible = true }.getDelegate(node) as R2dbcInnerTableLinkAccessor<*, *, *, *>).link.sourceColumn + val children = entityCache.getReferrers(node.id, sourceColumn) + assertEqualLists(children?.toList().orEmpty(), values) + } + + Node.all().with(Node::children).toList() + checkChildrenReferences(child1, emptyList()) + checkChildrenReferences(child2, emptyList()) + checkChildrenReferences(root1, listOf(child1)) + checkChildrenReferences(root2, listOf(child1, child2)) + + suspend fun checkParentsReferences(node: Node, values: List) { + val sourceColumn = (Node::parents.apply { isAccessible = true }.getDelegate(node) as R2dbcInnerTableLinkAccessor<*, *, *, *>).link.sourceColumn + val children = entityCache.getReferrers(node.id, sourceColumn) + assertEqualLists(children?.toList().orEmpty(), values) + } + + Node.all().with(Node::parents).toList() + checkParentsReferences(child1, listOf(root1, root2)) + checkParentsReferences(child2, listOf(root2)) + checkParentsReferences(root1, emptyList()) + checkParentsReferences(root2, emptyList()) + } + } + + class NodeOrdered(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(NodesTable) + + var name by NodesTable.name + val parents by NodeOrdered.viaSuspend(NodeToNodes.child, NodeToNodes.parent) + val children by NodeOrdered.viaSuspend(NodeToNodes.parent, NodeToNodes.child) orderBy (NodesTable.name to SortOrder.ASC) + + override fun equals(other: Any?): Boolean = (other as? NodeOrdered)?.id == id + + override fun hashCode(): Int = Objects.hash(id) + } + + @Test + fun testOrderBy() { + withTables(NodesTable, NodeToNodes) { + val root = NodeOrdered.new { name = "root" } + listOf("#3", "#0", "#2", "#4", "#1").forEach { + val n = NodeOrdered.new { + name = it + } + // TODO same problem with `new(){}` block + n.parents set listOf(root) + } + + root.children().toList().forEachIndexed { index, node -> + assertEquals("#$index", node.name) + } + } + } + + object Projects : IntIdTable("projects") { + val name = varchar("name", 50) + } + + class Project(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Projects) + + var name by Projects.name + val tasks by Task viaSuspend ProjectTasks + } + + object ProjectTasks : CompositeIdTable("project_tasks") { + val project = reference("project", Projects, onDelete = ReferenceOption.CASCADE) + val task = reference("task", Tasks, onDelete = ReferenceOption.CASCADE) + val approved = bool("approved") + + override val primaryKey = PrimaryKey(project, task) + + init { + addIdColumn(project) + addIdColumn(task) + } + } + + class ProjectTask(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(ProjectTasks) + + var approved by ProjectTasks.approved + } + + object Tasks : IntIdTable("tasks") { + val title = varchar("title", 64) + } + + class Task(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Tasks) + + var title by Tasks.title + val approved by ProjectTasks.approved + } + + @Test + fun testAdditionalLinkDataUsingCompositeIdInnerTable() { + withTables(Projects, Tasks, ProjectTasks) { + val p1 = Project.new { name = "Project 1" } + val p2 = Project.new { name = "Project 2" } + val t1 = Task.new { title = "Task 1" } + val t2 = Task.new { title = "Task 2" } + val t3 = Task.new { title = "Task 3" } + + ProjectTask.new( + CompositeID { + it[ProjectTasks.task] = t1.id + it[ProjectTasks.project] = p1.id + } + ) { approved = true } + ProjectTask.new( + CompositeID { + it[ProjectTasks.task] = t2.id + it[ProjectTasks.project] = p2.id + } + ) { approved = false } + ProjectTask.new( + CompositeID { + it[ProjectTasks.task] = t3.id + it[ProjectTasks.project] = p2.id + } + ) { approved = false } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + Project.all().with(Project::tasks) + val cache = TransactionManager.current().entityCache + + val p1Tasks = cache.getReferrers(p1.id, ProjectTasks.project)?.toList().orEmpty() + assertEqualLists(p1Tasks.map { it.id }, listOf(t1.id)) + assertTrue { p1Tasks.all { task -> task.approved } } + + val p2Tasks = cache.getReferrers(p2.id, ProjectTasks.project)?.toList().orEmpty() + assertEqualLists(p2Tasks.map { it.id }, listOf(t2.id, t3.id)) + assertFalse { p1Tasks.all { task -> !task.approved } } + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcWarmUpLinkedReferencesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcWarmUpLinkedReferencesTests.kt new file mode 100644 index 0000000000..9f29807647 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcWarmUpLinkedReferencesTests.kt @@ -0,0 +1,69 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test + +class R2dbcWarmUpLinkedReferencesTests : R2dbcDatabaseTestsBase() { + + object Box : IntIdTable() { + val value = integer("value") + } + + class EBox(id: EntityID) : IntR2dbcEntity(id) { + var value by Box.value + + companion object : IntR2dbcEntityClass(Box) + } + + object BoxItem : IntIdTable() { + val box = reference("box", Box) + + val value = integer("value") + } + + class EBoxItem(id: EntityID) : IntR2dbcEntity(id) { + var value by BoxItem.value + val box by EBox referencedOnSuspend BoxItem.box + + companion object : IntR2dbcEntityClass(BoxItem) + } + + @Test + fun warmUpLinkedReferencesShouldNotReturnAllTheValueFromCache() { + withTables(Box, BoxItem) { + val boxEntities = (0..4).map { + EBox.new { + value = it + } + } + + // TODO R2DBC: flush before reading `it.id.value` — `R2dbcDaoEntityID` cannot trigger a + // suspending `flushInserts` from a non-suspend property accessor, so we flush + // explicitly to populate the auto-increment ids before referencing them below. + flushCache() + + boxEntities.forEach { + val e = EBoxItem.new { + value = it.id.value + } + // TODO again setting out of `new()` + e.box set it + } + + val ids = boxEntities.map { it.id } + + // Warm up all the entities to fill the cache + EBox.warmUpLinkedReferences(ids, BoxItem) + + val warmedUp = EBox.warmUpLinkedReferences(ids.slice(0..2), BoxItem) + assertEquals(3, warmedUp.size) + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/CompositeEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/CompositeEntity.kt new file mode 100644 index 0000000000..6352029488 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/CompositeEntity.kt @@ -0,0 +1,13 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +abstract class CompositeR2dbcEntity(id: EntityID) : R2dbcEntity(id) + +abstract class CompositeR2dbcEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/EntityFieldWithTransform.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/EntityFieldWithTransform.kt new file mode 100644 index 0000000000..4ae8d33cf2 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/EntityFieldWithTransform.kt @@ -0,0 +1,40 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnTransformer + +/** + * Class responsible for enabling [R2dbcEntity] field transformations, which may be useful when advanced database + * type conversions are necessary for entity mappings. + * + * Mirrors JDBC's `EntityFieldWithTransform` — duplicated here rather than shared because `exposed-dao` is the + * JDBC-only DAO module and `exposed-dao-r2dbc` cannot depend on it. + */ +open class EntityFieldWithTransform( + /** The original column that will be transformed. */ + val column: Column, + /** Instance of [ColumnTransformer] with the transformation logic. */ + private val transformer: ColumnTransformer, + /** + * Whether the original and transformed values should be cached to avoid multiple conversion calls. + */ + protected val cacheResult: Boolean = false +) : ColumnTransformer { + private var cache: Pair? = null + + override fun unwrap(value: Wrapped): Unwrapped = transformer.unwrap(value) + + /** The function used to transform a value stored in the original column type. */ + override fun wrap(value: Unwrapped): Wrapped { + return if (cacheResult) { + val localCache = cache + if (localCache != null && localCache.first == value) { + localCache.second + } else { + transformer.wrap(value).also { cache = value to it } + } + } else { + transformer.wrap(value) + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt index 0dfff06cb0..3d5fa736c5 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt @@ -75,6 +75,21 @@ open class R2dbcEntity(val id: EntityID) { } } + /** + * Property delegate for [EntityFieldWithTransform] — reads the raw column value via [Column.getValue] + * and runs it through the transformer's `wrap` function (with optional memoization). + */ + operator fun EntityFieldWithTransform.getValue(o: R2dbcEntity, desc: KProperty<*>): Wrapped = + wrap(column.getValue(o, desc)) + + /** + * Property delegate for [EntityFieldWithTransform] — runs the supplied value through the transformer's + * `unwrap` function and writes it back to the original column via [Column.setValue]. + */ + operator fun EntityFieldWithTransform.setValue(o: R2dbcEntity, desc: KProperty<*>, value: Wrapped) { + column.setValue(o, desc, unwrap(value)) + } + @Suppress("UNCHECKED_CAST") internal fun writeIdColumnValue(table: IdTable<*>, value: EntityID<*>) { (value._value as? CompositeID)?.let { id -> @@ -134,8 +149,11 @@ open class R2dbcEntity(val id: EntityID) { val _writeValues = writeValues.toMap() storeWrittenValues() - @Suppress("ForbiddenComment") - // TODO: Implement entity change tracking when subscriptions are implemented + val transaction = TransactionManager.current() + + @Suppress("UNCHECKED_CAST") + transaction.registerChange(klass as R2dbcEntityClass<*, R2dbcEntity<*>>, id, EntityChangeType.Updated) + executeAsPartOfEntityLifecycle { table.update({ table.id eq id }) { for ((c, v) in _writeValues) { @@ -143,7 +161,6 @@ open class R2dbcEntity(val id: EntityID) { } } } - // TODO: Implement alertSubscribers when subscriptions are implemented } else { batch.addBatch(this) for ((c, v) in writeValues) { @@ -160,10 +177,14 @@ open class R2dbcEntity(val id: EntityID) { open suspend fun delete() { val table = klass.table val entityId = this.id - // TODO add register change like in JDBCHello. // TODO should we insert before and then remove - // (extra requests, but probablyt correctness could be better) + // (extra requests, but probably correctness could be better) if (!isNewEntity()) { + val transaction = TransactionManager.current() + + @Suppress("UNCHECKED_CAST") + transaction.registerChange(klass as R2dbcEntityClass<*, R2dbcEntity<*>>, entityId, EntityChangeType.Removed) + executeAsPartOfEntityLifecycle { table.deleteWhere { table.id eq entityId } } @@ -212,6 +233,8 @@ open class R2dbcEntity(val id: EntityID) { * * Counterpart of JDBC's `via`. Named `viaSuspend` to match the rest of the R2DBC DAO API. */ + // TODO ALIGN_WITH_JDBC: name diverges from JDBC's `via`; revisit if/when the relationship DSL + // is unified across the JDBC and R2DBC DAO modules. infix fun > R2dbcEntityClass.viaSuspend( table: Table ): R2dbcInnerTableLink, TID, Target> = diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt index 9370d77ee8..630cdca851 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityCache.kt @@ -1,6 +1,7 @@ package org.jetbrains.exposed.r2dbc.dao import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.transactions.transactionScope @@ -26,17 +27,14 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { internal val updates = ConcurrentHashMap, MutableSet>>() - internal val referrers = ConcurrentHashMap, MutableMap, Any>>() + internal val referrers = ConcurrentHashMap, MutableMap, SizedIterable<*>>>() - fun > find(entityClass: R2dbcEntityClass, id: EntityID): T? { - // `id.value` can not be used, because it can't insert the entity in the case it's null - if (id._value == null) { - return inserts[entityClass.table]?.firstOrNull { it.id == id } as? T - ?: initializingEntities.firstOrNull { it.klass == entityClass && it.id == id } as? T - } - - return getMap(entityClass)[id.value] as T? - } + fun > find(f: R2dbcEntityClass, id: EntityID): T? = + // Mirrors JDBC's `EntityCache.find`. Unlike JDBC we can't dereference `id.value` blindly + // (it would throw on an un-flushed entity), so the first lookup is gated by `id._value`. + (id._value?.let { getMap(f)[it] as T? }) + ?: inserts[f.table]?.firstOrNull { it.id == id } as? T + ?: initializingEntities.firstOrNull { it.klass == f && it.id == id } as? T private fun getMap(f: R2dbcEntityClass<*, *>): MutableMap> = getMap(f.table) @@ -59,19 +57,25 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { } } - fun store(entity: R2dbcEntity) { - val map = data.getOrPut(entity.klass.table) { ConcurrentHashMap() } - map[entity.id.value] = entity + /** Stores the specified [R2dbcEntity] in this cache using its associated [R2dbcEntityClass] as the key. */ + fun > store(f: R2dbcEntityClass, o: T) { + getMap(f)[o.id.value] = o } - fun remove(table: IdTable, entity: R2dbcEntity) { - // Same as in find(), 'entity.id.value' can not be used directly, because - // because the entity could not be fetched in this moment. Another option is to - // make 'remove()' suspend - if (entity.id._value != null) { - data[table]?.remove(entity.id._value) - } - inserts[table]?.remove(entity) + /** + * Stores the specified [R2dbcEntity] in this cache. + * + * The [R2dbcEntityClass] associated with this entity is inferred from its [R2dbcEntity.klass] property. + */ + fun store(o: R2dbcEntity<*>) { + getMap(o.klass.table)[o.id.value] = o + } + + fun > remove(table: IdTable, o: T) { + // Mirrors JDBC's `EntityCache.remove`. Guard around `id._value`: in R2DBC an un-flushed + // entity's `id.value` would throw (no `invokeOnNoValue` flush), so just skip — the entity + // can't be in `data` yet. + o.id._value?.let { getMap(table).remove(it) } } fun scheduleUpdate(klass: R2dbcEntityClass>, entity: R2dbcEntity) { @@ -113,6 +117,7 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { inserts.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) } + // TODO parameters have other order rather tahn in jdbc alternative suspend fun > getOrPutReferrers( column: Column<*>, sourceId: EntityID<*>, @@ -132,6 +137,18 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { referrers[column]?.remove(entityId) } + suspend fun clear(flush: Boolean = true) { + if (flush) flush() + data.clear() + inserts.clear() + updates.clear() + clearReferrersCache() + } + + fun clearReferrersCache() { + referrers.clear() + } + suspend fun updateEntities(table: IdTable) { val entitiesToUpdate = updates.remove(table)?.toList().orEmpty() if (entitiesToUpdate.isEmpty()) return @@ -154,43 +171,48 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { } suspend fun flush(tables: Iterable>) { - if (flushingEntities) { - return - } - + if (flushingEntities) return try { flushingEntities = true - val insertedTables = inserts.keys + val updateBeforeInsert = SchemaUtils.sortTablesByReferences(insertedTables).filterIsInstance>() - for (table in updateBeforeInsert) { - updateEntities(table) - } + updateBeforeInsert.forEach { updateEntities(it) } - val tablesToInsert = SchemaUtils.sortTablesByReferences(insertedTables).filterIsInstance>() - for (table in tablesToInsert) { - flushInserts(table) - } + SchemaUtils.sortTablesByReferences(tables).filterIsInstance>().forEach { flushInserts(it) } - val updateTheRestTables = tables.toSet() - updateBeforeInsert.toSet() + val updateTheRestTables = tables - updateBeforeInsert.toSet() for (t in updateTheRestTables) { updateEntities(t) } if (insertedTables.isNotEmpty()) { - removeTablesReferrers(insertedTables) + removeTablesReferrers(insertedTables, true) } } finally { flushingEntities = false } } - fun removeTablesReferrers(tables: Collection>) { - val columnsToRemove = referrers.keys.filter { column -> - tables.any { table -> column.table == table } + internal fun removeTablesReferrers(tables: Collection
, isInsert: Boolean) { + val insertedTablesSet = tables.toSet() + val columnsToInvalidate = tables.flatMapTo(hashSetOf()) { table -> + table.columns.mapNotNull { column -> column.takeIf { it.referee != null } } } - columnsToRemove.forEach { column -> - referrers.remove(column) + + columnsToInvalidate.forEach { + referrers.remove(it) + } + + referrers.keys.filter { refColumn -> + when { + isInsert -> false + refColumn.referee?.table in insertedTablesSet -> true + refColumn.table.columns.any { it.referee?.table in tables } -> true + else -> false + } + }.forEach { + referrers.remove(it) } } @@ -243,11 +265,15 @@ class R2dbcEntityCache(private val transaction: R2dbcTransaction) { } store(entity) + + transaction.registerChange(entity.klass, entity.id, EntityChangeType.Created) } for (entity in entitiesWithSelfRefs) { entity.flush() } + + transaction.alertSubscribers() } } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt index ec5316c840..22111b3cd2 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -1,17 +1,37 @@ package org.jetbrains.exposed.r2dbc.dao +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.forEach +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.flow.toList import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException +import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcBackReference +import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcOptionalBackReference +import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcReferrers import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnSet +import org.jetbrains.exposed.v1.core.ColumnTransformer +import org.jetbrains.exposed.v1.core.ColumnWithTransform +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.ExpressionWithColumnType +import org.jetbrains.exposed.v1.core.Join +import org.jetbrains.exposed.v1.core.JoinType import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.columnTransformer +import org.jetbrains.exposed.v1.core.count import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList import org.jetbrains.exposed.v1.r2dbc.Query +import org.jetbrains.exposed.v1.r2dbc.SizedCollection import org.jetbrains.exposed.v1.r2dbc.SizedIterable +import org.jetbrains.exposed.v1.r2dbc.emptySized import org.jetbrains.exposed.v1.r2dbc.mapLazy import org.jetbrains.exposed.v1.r2dbc.select import org.jetbrains.exposed.v1.r2dbc.selectAll @@ -140,6 +160,8 @@ abstract class R2dbcEntityClass>( * be deprecated, but it sounds like a bad idea in terms of API changes (even on major versions), * because it could be used by many users. */ + // TODO ALIGN_WITH_JDBC: no JDBC counterpart — JDBC's `Column.setValue` auto-attaches the entity + // by querying the DB synchronously. Revisit when/if R2DBC gains an equivalent implicit path. suspend fun attach(entity: R2dbcEntity) { val cache = warmCache() if (cache.find(this, entity.id) != null) return @@ -168,12 +190,33 @@ abstract class R2dbcEntityClass>( wrapRow(it) } + /** + * Wraps the specified [ResultRow] data into an [R2dbcEntity] instance. + * + * When an entity is already cached, the method performs a **selective merge**: values for + * columns present in [row] are used to refresh the entity, while columns absent from [row] + * (e.g. a partial SELECT) retain their previously cached values. + * + * Mirrors JDBC's fix for GitHub issue #1527 — without the merge, a cached entity returned by + * `SELECT FOR UPDATE` would silently keep its stale `_readValues`, causing lost updates in + * concurrent increment patterns. + */ @Suppress("MemberVisibilityCanBePrivate") fun wrapRow(row: ResultRow): T { val entity = wrap(row[table.id], row) + if (entity._readValues == null) { entity._readValues = row + return entity } + + val existingKeys = entity.readValues.fieldIndex.keys + val fetchedKeys = row.fieldIndex.keys + val columnToValue = (existingKeys + fetchedKeys).toSet().associateWith { column -> + if (row.hasValue(column)) row[column] else entity._readValues?.get(column) + } + entity._readValues = ResultRow.createAndFillValues(unwrapColumnValues(columnToValue)) + return entity } @@ -190,14 +233,268 @@ abstract class R2dbcEntityClass>( suspend operator fun get(id: ID): T = get(R2dbcDaoEntityID(id, table)) - @Suppress("ForbiddenComment") fun removeFromCache(entity: R2dbcEntity) { val cache = warmCache() cache.remove(table, entity) - cache.referrers.forEach { (_, referrers) -> + // R2DBC's `Entity.delete` skips the round-trip INSERT+DELETE for unflushed entities, so we + // also need to drop the entity from the scheduled inserts. JDBC doesn't need this because + // the lifecycle interceptor flushes inserts before the DELETE statement. + cache.inserts[table]?.remove(entity) + cache.referrers.forEach { (col, referrers) -> + // Remove references from entity to other entities referrers.remove(entity.id) - // TODO Remove references from other entities to this entity + // Remove references from other entities to this entity + if (col.table == table) { + with(entity) { col.lookup() }?.let { referrers.remove(it as EntityID<*>) } + } } } + + /** + * One-to-many reference. R2DBC counterpart of JDBC's `referrersOn`. + */ + // TODO ALIGN_WITH_JDBC: the relationship DSL is suffixed `*Suspend` (referrersOnSuspend, + // optionalReferrersOnSuspend, backReferencedOnSuspend, optionalBackReferencedOnSuspend) to + // disambiguate from the JDBC names that return entities directly. Once a unified naming + // scheme is agreed across modules, drop the suffix here. + @Suppress("UNCHECKED_CAST") + infix fun , REF> R2dbcEntityClass.referrersOnSuspend( + column: Column + ): R2dbcReferrers, TargetID, Target, REF> = + R2dbcReferrers(column, this, cache = true) + + /** Two-argument form to control caching behaviour. */ + fun , REF> R2dbcEntityClass.referrersOnSuspend( + column: Column, + cache: Boolean + ): R2dbcReferrers, TargetID, Target, REF> = + R2dbcReferrers(column, this, cache) + + /** + * Optional one-to-many reference. R2DBC counterpart of JDBC's `optionalReferrersOn`. + */ + infix fun , REF : Any> + R2dbcEntityClass.optionalReferrersOnSuspend( + column: Column + ): R2dbcReferrers, TargetID, Target, REF?> = + R2dbcReferrers(column, this, cache = true) + + /** Two-argument form to control caching behaviour. */ + fun , REF : Any> + R2dbcEntityClass.optionalReferrersOnSuspend( + column: Column, + cache: Boolean + ): R2dbcReferrers, TargetID, Target, REF?> = + R2dbcReferrers(column, this, cache) + + /** + * One-to-one back reference. R2DBC counterpart of JDBC's `backReferencedOn`. + */ + infix fun , REF> + R2dbcEntityClass.backReferencedOnSuspend( + column: Column + ): R2dbcBackReference, REF> = + R2dbcBackReference(column, this) + + /** + * Optional one-to-one back reference. R2DBC counterpart of JDBC's `optionalBackReferencedOn`. + */ + infix fun , REF> + R2dbcEntityClass.optionalBackReferencedOnSuspend( + column: Column + ): R2dbcOptionalBackReference, REF> = + R2dbcOptionalBackReference(column, this) + + /** + * Overload of [optionalBackReferencedOnSuspend] for non-nullable reference columns. + * Mirrors JDBC's overloaded `optionalBackReferencedOn(Column)`. + */ + @Suppress("UNCHECKED_CAST") + @JvmName("optionalBackReferencedOnSuspendNonNullable") + infix fun , REF : Any> + R2dbcEntityClass.optionalBackReferencedOnSuspend( + column: Column + ): R2dbcOptionalBackReference, REF> = + R2dbcOptionalBackReference(column as Column, this) + + @Suppress("UNCHECKED_CAST") + suspend fun warmUpLinkedReferences( + references: List>, + linkTable: Table, + forUpdate: Boolean? = null, + optimizedLoad: Boolean = false + ): List { + if (references.isEmpty()) return emptyList() + + val sourceRefColumn = linkTable.columns + .singleOrNull { it.referee == references.first().table.id } as? Column> + ?: error("Can't detect source reference column") + val targetRefColumn = linkTable.columns + .singleOrNull { it.referee == table.id } as? Column> + ?: error("Can't detect target reference column") + + return warmUpLinkedReferences(references, sourceRefColumn, targetRefColumn, linkTable, forUpdate, optimizedLoad) + } + + @Suppress("UNCHECKED_CAST", "ComplexMethod", "LongMethod") + suspend fun warmUpLinkedReferences( + references: List>, + sourceRefColumn: Column>, + targetRefColumn: Column>, + linkTable: Table, + forUpdate: Boolean? = null, + optimizedLoad: Boolean = false + ): List { + if (references.isEmpty()) return emptyList() + val distinctRefIds = references.distinct() + val transaction = TransactionManager.current() + + val inCache = transaction.entityCache.referrers[sourceRefColumn] + ?.filterKeys { distinctRefIds.contains(it) } + ?: emptyMap() + + val loaded = ((distinctRefIds - inCache.keys).takeIf { it.isNotEmpty() } as List>?)?.let { idsToLoad -> + val alreadyInJoin = (dependsOnTables as? Join)?.alreadyInJoin(linkTable) ?: false + val entityTables = if (alreadyInJoin) dependsOnTables else dependsOnTables.join(linkTable, JoinType.INNER, targetRefColumn, table.id) + + val columns = when { + optimizedLoad -> listOf(sourceRefColumn, targetRefColumn) + alreadyInJoin -> (dependsOnColumns + sourceRefColumn).distinct() + else -> (dependsOnColumns + linkTable.columns + sourceRefColumn).distinct() + } + + val query = entityTables.select(columns).where { sourceRefColumn inList idsToLoad } + val targetEntities = mutableMapOf, T>() + val entitiesWithRefs = when (forUpdate) { + true -> query.forUpdate() + false -> query.notForUpdate() + else -> query + }.map { + val targetId = it[targetRefColumn] + if (!optimizedLoad) { + targetEntities.getOrPut(targetId) { wrapRow(it) } + } + it[sourceRefColumn] to targetId + } + + if (optimizedLoad) { + forEntityIds(entitiesWithRefs.map { it.second }.toList()).collect { + targetEntities[it.id] = it + } + } + + val groupedBySourceId = entitiesWithRefs.toList().groupBy({ it.first }) { targetEntities.getValue(it.second) } + + idsToLoad.forEach { + transaction.entityCache.getOrPutReferrers(sourceRefColumn, it) { + SizedCollection(groupedBySourceId[it] ?: emptyList()) + } + } + targetEntities.values + } + + return inCache.values.flatMap { it.toList() as List } + loaded.orEmpty() + } + + open fun forEntityIds(ids: List>): SizedIterable { + val distinctIds = ids.distinct() + if (distinctIds.isEmpty()) return emptySized() + + val cached = distinctIds.mapNotNull { testCache(it) } + + if (cached.size == distinctIds.size) { + return SizedCollection(cached) + } + + return wrapRows(searchQuery(table.id inList distinctIds)) + } + + /** + * Returns an [EntityFieldWithTransform] delegate that transforms this stored [Unwrapped] value on every read. + * + * @param transformer An instance of [ColumnTransformer] to handle the transformations. + */ + fun Column.transform( + transformer: ColumnTransformer + ): EntityFieldWithTransform = EntityFieldWithTransform(this, transformer, false) + + /** + * Returns an [EntityFieldWithTransform] delegate that transforms this stored [Unwrapped] value on every read. + * + * @param unwrap A pure function that converts a transformed value to a value that can be stored in this original column type. + * @param wrap A pure function that transforms a value stored in this original column type. + */ + fun Column.transform( + unwrap: (Wrapped) -> Unwrapped, + wrap: (Unwrapped) -> Wrapped + ): EntityFieldWithTransform = transform(columnTransformer(unwrap, wrap)) + + /** + * Returns an [EntityFieldWithTransform] that extends transformation of an existing [EntityFieldWithTransform]. + * + * @param unwrap A function that transforms the value to the wrapping type of the previously defined transformation. + * @param wrap A function that transforms the value to the wrapping type. + */ + fun EntityFieldWithTransform.transform( + unwrap: (Wrapped) -> Unwrapped, + wrap: (Unwrapped) -> Wrapped + ): EntityFieldWithTransform = + EntityFieldWithTransform(this.column, columnTransformer({ this.unwrap(unwrap(it)) }, { wrap(this.wrap(it)) }), false) + + /** + * Returns an [EntityFieldWithTransform] delegate that caches the transformed value on first read of + * this same stored [Unwrapped] value. + * + * @param transformer An instance of [ColumnTransformer] to handle the transformations. + */ + fun Column.memoizedTransform( + transformer: ColumnTransformer + ): EntityFieldWithTransform = EntityFieldWithTransform(this, transformer, true) + + /** + * Returns an [EntityFieldWithTransform] delegate that caches the transformed value on first read of + * this same stored [Unwrapped] value. + */ + fun Column.memoizedTransform( + unwrap: (Wrapped) -> Unwrapped, + wrap: (Unwrapped) -> Wrapped + ): EntityFieldWithTransform = memoizedTransform(columnTransformer(unwrap, wrap)) + + /** + * Returns an [EntityFieldWithTransform] that extends transformation of an existing [EntityFieldWithTransform] + * and caches the transformed value on first read. + */ + fun EntityFieldWithTransform.memoizedTransform( + unwrap: (Wrapped) -> Unwrapped, + wrap: (Unwrapped) -> Wrapped + ): EntityFieldWithTransform = EntityFieldWithTransform( + this.column, + columnTransformer({ this.unwrap(unwrap(it)) }, { wrap(this.wrap(it)) }), + true + ) + + suspend fun count(op: Op? = null): Long { + val countExpression = table.idColumns.first().count() + val query = table.select(countExpression).notForUpdate() + op?.let { query.adjustWhere { op } } + return query.first()[countExpression] + } + + /** + * Returns whether the [entityClass] type is equivalent to or a superclass of this [R2dbcEntityClass] instance's [klass]. + * Mirrors JDBC's `EntityClass.isAssignableTo`. + */ + fun > isAssignableTo(entityClass: R2dbcEntityClass) = + entityClass.klass.isAssignableFrom(klass) +} + +/** + * Unwraps any [ColumnWithTransform] values down to the underlying column type. Used by + * [R2dbcEntityClass.wrapRow]'s selective-merge path so transformed columns aren't re-wrapped + * when their values are re-stored into [R2dbcEntity._readValues]. Mirrors JDBC's helper. + */ +internal fun > unwrapColumnValues(values: Map): Map = values.mapValues { (col, value) -> + if (col !is ExpressionWithColumnType<*>) return@mapValues value + value?.let { (col.columnType as? ColumnWithTransform)?.unwrapRecursive(it) } ?: value } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityHook.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityHook.kt new file mode 100644 index 0000000000..623463d402 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityHook.kt @@ -0,0 +1,135 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.transactions.transactionScope +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import java.util.Deque +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.ConcurrentLinkedQueue + +/** + * R2DBC counterpart of `EntityChangeType` from `exposed-dao`. The values mirror the JDBC enum; + * a separate copy lives in this module because the lifecycle is owned by `R2dbcEntityClass`, + * which doesn't share an interface with `EntityClass`. + */ +enum class EntityChangeType { + /** The entity has been inserted in the database. */ + Created, + + /** The entity has been updated in the database. */ + Updated, + + /** The entity has been removed from the database. */ + Removed +} + +/** + * Stores details about a state-change event for an [R2dbcEntity] instance. R2DBC counterpart of + * `EntityChange` from `exposed-dao`. + */ +// TODO it is almost the same as EntityChange in JDBC DAO, but depends on R2dbcEntityClass and R2dbcEntity +data class EntityChange( + /** The [R2dbcEntityClass] of the changed entity instance. */ + val entityClass: R2dbcEntityClass<*, R2dbcEntity<*>>, + /** The unique [EntityID] associated with the entity instance. */ + val entityId: EntityID<*>, + /** The exact changed state of the event. */ + val changeType: EntityChangeType, + /** The unique id for the [R2dbcTransaction] in which the event took place. */ + val transactionId: String +) + +/** + * Returns the actual [R2dbcEntity] instance associated with [this][EntityChange] event, + * or `null` if the entity is not found. + */ +@Suppress("UNCHECKED_CAST") +suspend fun > EntityChange.toEntity(): T? = + (entityClass as R2dbcEntityClass).findById(entityId as EntityID) + +/** + * Returns the actual [R2dbcEntity] instance associated with [this][EntityChange] event, + * or `null` if either its class type is neither equivalent to nor a subclass of [klass], + * or if the entity is not found. + */ +suspend fun > EntityChange.toEntity(klass: R2dbcEntityClass): T? { + if (!entityClass.isAssignableTo(klass)) return null + return toEntity() +} + +private val R2dbcTransaction.unprocessedEvents: Deque by transactionScope { ConcurrentLinkedDeque() } +private val R2dbcTransaction.entityEvents: Deque by transactionScope { ConcurrentLinkedDeque() } +private val entitySubscribers = ConcurrentLinkedQueue Unit>() + +/** + * R2DBC counterpart of `EntityHook` from `exposed-dao`. Subscribers are `suspend` because the + * R2DBC lifecycle runs inside coroutines. + */ +object EntityHook { + /** + * Registers a specific state-change [action] for alerts and returns the [action]. + */ + fun subscribe(action: suspend (EntityChange) -> Unit): suspend (EntityChange) -> Unit { + entitySubscribers.add(action) + return action + } + + /** Unregisters a specific state-change [action] from alerts. */ + fun unsubscribe(action: suspend (EntityChange) -> Unit) { + entitySubscribers.remove(action) + } +} + +/** Creates a new [EntityChange] with [this][R2dbcTransaction] id and registers it as an entity event. */ +fun R2dbcTransaction.registerChange( + entityClass: R2dbcEntityClass<*, R2dbcEntity<*>>, + entityId: EntityID<*>, + changeType: EntityChangeType +) { + EntityChange(entityClass, entityId, changeType, transactionId).let { + if (unprocessedEvents.peekLast() != it) { + unprocessedEvents.addLast(it) + entityEvents.addLast(it) + } + } +} + +private var isProcessingEventsLaunched by transactionScope { false } + +/** + * Triggers alerts for all unprocessed entity events using any state-change actions previously + * registered via [EntityHook.subscribe]. + */ +suspend fun R2dbcTransaction.alertSubscribers() { + if (isProcessingEventsLaunched) return + while (true) { + try { + isProcessingEventsLaunched = true + val event = unprocessedEvents.pollFirst() ?: break + entitySubscribers.forEach { it(event) } + } finally { + isProcessingEventsLaunched = false + } + } +} + +/** Returns a list of all [EntityChange] events that have been registered in this [R2dbcTransaction]. */ +fun R2dbcTransaction.registeredChanges(): List = entityEvents.toList() + +/** + * Calls the specified [body] with the given state-change [action], registers the action, and + * returns its result. + * + * The [action] will be unregistered at the end of the call to the [body] block. + */ +suspend fun withHook(action: suspend (EntityChange) -> Unit, body: suspend () -> T): T { + EntityHook.subscribe(action) + return try { + body().also { + TransactionManager.currentOrNull()?.commit() + } + } finally { + EntityHook.unsubscribe(action) + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt index 3b5c9084f7..e3c174a7d8 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityLifecycleInterceptor.kt @@ -43,9 +43,9 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { is DeleteStatement -> { transaction.flushCache() - transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables(), false) if (!isExecutedWithinEntityLifecycle) { - statement.targetsSet.targetTables().filterIsInstance>().forEach { + statement.targets.filterIsInstance>().forEach { transaction.entityCache.data[it]?.clear() } } @@ -53,7 +53,7 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { is UpsertStatement<*>, is BatchUpsertStatement -> { transaction.flushCache() - transaction.entityCache.removeTablesReferrers(statement.targets.filterIsInstance>()) + transaction.entityCache.removeTablesReferrers(statement.targets, true) if (!isExecutedWithinEntityLifecycle) { statement.targets.filterIsInstance>().forEach { transaction.entityCache.data[it]?.clear() @@ -63,16 +63,17 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { is InsertStatement<*> -> { transaction.flushCache() - if (statement.table is IdTable<*>) { - transaction.entityCache.removeTablesReferrers(listOf(statement.table as IdTable<*>)) - } + transaction.entityCache.removeTablesReferrers(listOf(statement.table), true) + } + + is BatchUpdateStatement -> { } is UpdateStatement -> { transaction.flushCache() - transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables().filterIsInstance>()) + transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables(), false) if (!isExecutedWithinEntityLifecycle) { - statement.targetsSet.targetTables().filterIsInstance>().forEach { + statement.targets.filterIsInstance>().forEach { transaction.entityCache.data[it]?.clear() } } @@ -84,28 +85,34 @@ class R2dbcEntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { } } - @Suppress("ForbiddenComment") override suspend fun afterExecution( transaction: R2dbcTransaction, contexts: List, executedStatement: R2dbcPreparedStatementApi ) { - // TODO: Implement alertSubscribers when subscriptions are implemented + if (!isExecutedWithinEntityLifecycle || contexts.first().statement !is InsertStatement<*>) { + transaction.alertSubscribers() + } } - @Suppress("ForbiddenComment") override suspend fun beforeCommit(transaction: R2dbcTransaction) { transaction.flushCache() - // TODO: Implement alertSubscribers and EntityCache.invalidateGlobalCaches when subscriptions are implemented + transaction.alertSubscribers() + transaction.flushCache() + // TODO ALIGN_WITH_JDBC: call `EntityCache.invalidateGlobalCaches(created + createdByHooks)` + // once `ImmutableCachedEntityClass` exists in R2DBC. } override suspend fun beforeRollback(transaction: R2dbcTransaction) { val entityCache = transaction.entityCache - // Clear referrers cache - entityCache.referrers.clear() + entityCache.clearReferrersCache() // Clear writeValues and readValues for all entities before clearing the cache to prevent - // stale data from being carried over into a new transaction + // stale data from being carried over into a new transaction. Ideally, at this stage, + // values from writeValues should not have been transferred to readValues yet, but we clear + // both for reliability to ensure complete cleanup. + // + // TODO ALIGN_WITH_JDBC: when ImmutableCachedEntityClass is ported, preserve its _readValues here. entityCache.data.values.forEach { entityMap -> entityMap.values.forEach { entity -> entity.writeValues.clear() diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UIntEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UIntEntity.kt new file mode 100644 index 0000000000..1cb38fec85 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UIntEntity.kt @@ -0,0 +1,12 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +abstract class UIntR2dbcEntity(id: EntityID) : R2dbcEntity(id) + +abstract class UIntR2dbcEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/ULongEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/ULongEntity.kt new file mode 100644 index 0000000000..c54d608f83 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/ULongEntity.kt @@ -0,0 +1,12 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +abstract class ULongR2dbcEntity(id: EntityID) : R2dbcEntity(id) + +abstract class ULongR2dbcEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UuidEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UuidEntity.kt new file mode 100644 index 0000000000..c8f76bd25c --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/UuidEntity.kt @@ -0,0 +1,16 @@ +package org.jetbrains.exposed.r2dbc.dao + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +abstract class UuidR2dbcEntity(id: EntityID) : R2dbcEntity(id) + +@OptIn(ExperimentalUuidApi::class) +abstract class UuidR2dbcEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/java/UUIDEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/java/UUIDEntity.kt new file mode 100644 index 0000000000..9547b8acc8 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/java/UUIDEntity.kt @@ -0,0 +1,15 @@ +package org.jetbrains.exposed.r2dbc.dao.java + +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import java.util.UUID + +abstract class UUIDR2dbcEntity(id: EntityID) : R2dbcEntity(id) + +abstract class UUIDR2dbcEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : R2dbcEntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt index a68395ed46..714ee86604 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt @@ -25,7 +25,13 @@ import kotlin.reflect.jvm.isAccessible * * Returns this [SizedIterable] to allow chaining; the loaded list is also pinned onto any * [LazySizedIterable] so subsequent iterations do not re-query the database. + * + * Note: R2DBC's [SizedIterable] extends `Flow` (not `Iterable` as in JDBC), so we provide + * a separate [Iterable.with] overload below — they cannot share one generic receiver. */ +// TODO ALIGN_WITH_JDBC: JDBC has a single `Iterable.with` because its SizedIterable is also +// an Iterable. R2DBC has to expose two overloads (this one + the Iterable overload below). If +// R2DBC's SizedIterable ever stops being `Flow`-only, these can collapse into one. suspend fun , REF : R2dbcEntity<*>, L : SizedIterable> L.with( vararg relations: KProperty1 ): L { @@ -40,16 +46,32 @@ suspend fun , REF : R2dbcEntity<*>, L : Si return this } +/** + * Eager-loads the specified [relations] for all entities in this in-memory [Iterable] (e.g. a + * plain `List`). Mirrors JDBC's `Iterable.with`. This overload exists because R2DBC's + * [SizedIterable] is a `Flow`, not an `Iterable`, so the two receivers cannot be unified. + */ +suspend fun , REF : R2dbcEntity<*>, L : Iterable> L.with( + vararg relations: KProperty1 +): L { + val asList = toList() + if (asList.any { it.isNewEntity() }) { + TransactionManager.current().flushCache() + } + asList.preloadRelations(*relations) + return this +} + /** * Eager-loads the specified [relations] for this entity. Mirrors JDBC's `Entity.load`. */ suspend fun > SRC.load( vararg relations: KProperty1, Any?> ): SRC = apply { - SizedCollection(listOf(this)).with(*relations) + listOf(this).with(*relations) } -@Suppress("UNCHECKED_CAST", "ForbiddenComment") +@Suppress("UNCHECKED_CAST") private suspend fun List>.preloadRelations( vararg relations: KProperty1, Any?>, nodesVisited: MutableSet> = mutableSetOf() @@ -107,7 +129,19 @@ private suspend fun List>.preloadReference( val referee = reference.referee ?: return emptyList() val condition = buildInListCondition(referee, refIds.distinct()) - return factory.find { condition }.toList() + val loadedParents = factory.find { condition }.toList() + + // Mirrors JDBC's `storeReferenceCache`: pin the loaded parent on each child's per-entity + // reference cache so reads work after the transaction ends under + // `keepLoadedReferencesOutOfTransaction = true`. + val parentByKey = loadedParents.indexedByRefereeValue(referee) + forEach { child -> + val refValue = child.lookupRefValue(reference) ?: return@forEach + val parent = parentByKey[normalizeRefKey(refValue)] ?: return@forEach + child.storeReferenceInCache(reference, parent) + } + + return loadedParents } /** @@ -120,11 +154,39 @@ private suspend fun List>.preloadOptionalReference( val reference = accessor.reference as Column val factory = accessor.factory val refIds = mapNotNull { entity -> entity.lookupRefValue(reference) } - if (refIds.isEmpty()) return emptyList() val referee = reference.referee ?: return emptyList() - val condition = buildInListCondition(referee, refIds.distinct()) - return factory.find { condition }.toList() + val loadedParents = if (refIds.isEmpty()) { + emptyList() + } else { + val condition = buildInListCondition(referee, refIds.distinct()) + factory.find { condition }.toList() + } + + val parentByKey = loadedParents.indexedByRefereeValue(referee) + forEach { child -> + val refValue = child.lookupRefValue(reference) + if (refValue == null) { + child.storeReferenceInCache(reference, null) + } else { + parentByKey[normalizeRefKey(refValue)]?.let { parent -> + child.storeReferenceInCache(reference, parent) + } + } + } + + return loadedParents +} + +private fun normalizeRefKey(value: Any): Any = (value as? EntityID<*>)?.value ?: value + +private fun List>.indexedByRefereeValue(referee: Column<*>): Map> { + val result = HashMap>(size) + for (parent in this) { + val raw = parent.lookupRefValue(referee) ?: continue + result[normalizeRefKey(raw)] = parent + } + return result } /** @@ -166,7 +228,13 @@ private suspend fun List>.preloadReferrers( val refereeValuesToLoad = toLoadMappings.map { it.second }.distinct() val condition = buildInListCondition(refColumn, refereeValuesToLoad) - val loadedChildren = factory.find { condition }.toList() + // Honour any `orderBy` configured on the Referrers delegate (e.g. `referrersOnSuspend X orderBy Y`) + // so the bulk-loaded list matches the order the user would see from a single-parent fetch. + + @Suppress("SpreadOperator") + val loadedChildren = factory.find { condition } + .orderBy(*referrers.getOrderByExpressions()) + .toList() val grouped: Map>> = loadedChildren.groupBy { child -> @Suppress("UNCHECKED_CAST") @@ -183,6 +251,13 @@ private suspend fun List>.preloadReferrers( } } + val parentsById: Map, R2dbcEntity> = associateBy { it.id } + parentMappings.forEach { (parentId, refereeValue) -> + val parent = parentsById[parentId] ?: return@forEach + val children = SizedCollection(grouped[refereeValue] ?: emptyList()) + parent.storeReferenceInCache(refColumn, children) + } + return loadedChildren } @@ -230,6 +305,12 @@ private suspend fun List>.preloadInnerTableLink( } } + val parentsById: Map, R2dbcEntity> = associateBy { it.id } + distinctParentIds.forEach { id -> + val parent = parentsById[id] ?: return@forEach + parent.storeReferenceInCache(sourceColumn, SizedCollection(groupedBySourceId[id] ?: emptyList())) + } + return pairs.map { it.second }.distinct() } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt index c5723bf7bd..00f20efd77 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcInnerTableLink.kt @@ -1,10 +1,12 @@ package org.jetbrains.exposed.r2dbc.dao.relationships import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.EntityChangeType import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.r2dbc.dao.executeAsPartOfEntityLifecycle +import org.jetbrains.exposed.r2dbc.dao.registerChange import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable @@ -126,5 +128,16 @@ class R2dbcInnerTableLinkAccessor, ID : Any this[link.targetColumn] = targetId } } + + // current entity updated + tx.registerChange(entity.klass as R2dbcEntityClass<*, R2dbcEntity<*>>, entity.id, EntityChangeType.Updated) + + // linked entities updated + val targetClass = (value.firstOrNull() ?: oldValue.firstOrNull())?.klass + if (targetClass != null) { + (existingIds + targetIds).forEach { targetId -> + tx.registerChange(targetClass as R2dbcEntityClass<*, R2dbcEntity<*>>, targetId, EntityChangeType.Updated) + } + } } } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt index e7b617cc88..3d9ecf666c 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt @@ -4,6 +4,10 @@ import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.EntityIDColumnType +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.SizedIterable import org.jetbrains.exposed.v1.r2dbc.emptySized @@ -15,68 +19,82 @@ class R2dbcReferrers, ChildID val factory: R2dbcEntityClass, val cache: Boolean ) { - init { - // Validate that reference column points to the parent entity's table - val referee = reference.referee ?: error("Column $reference is not a reference") + /** The set of columns and their [SortOrder] for ordering referred entities in one-to-many relationship. */ + private val orderByExpressions = linkedSetOf, SortOrder>>() + + /** Returns the order by expressions as an array. */ + internal fun getOrderByExpressions(): Array, SortOrder>> = orderByExpressions.toTypedArray() - // Validate that reference column is on the child entity's table + init { + reference.referee ?: error("Column $reference is not a reference") if (factory.table != reference.table) { error("Column $reference and factory ${factory.table.tableName} point to different tables") } } - @Suppress("NestedBlockDepth", "ForbiddenComment") - operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> SizedIterable { - // Return a suspend lambda that will load the referrers when invoked - return { - // Check if entity ID is available + @Suppress("NestedBlockDepth", "SpreadOperator") + operator fun getValue(thisRef: Parent, property: KProperty<*>): suspend () -> SizedIterable = { + val transaction = TransactionManager.currentOrNull() + + if (transaction == null) { + // Out-of-transaction access falls back to the per-entity reference cache populated when + // `keepLoadedReferencesOutOfTransaction = true` (or by an eager loader like `with(...)`). if (thisRef.id._value == null) { - // TODO should it be error? emptySized() - } else { - val transaction = TransactionManager.currentOrNull() - - // Out-of-transaction access: return cached data - if (transaction == null) { - if (thisRef.hasInReferenceCache(reference)) { - val cached = thisRef.getReferenceFromCache(reference) - @Suppress("UNCHECKED_CAST") - when (cached) { - is SizedIterable<*> -> cached as SizedIterable - null -> emptySized() - else -> error("Cached referrer has unexpected type: ${cached::class}") - } - } else { - error("No transaction in context, and referrers not in entity cache for $reference") - } - } else { - // Get the parent entity's ID value to use in query - val referee = reference.referee!! - - @Suppress("UNCHECKED_CAST") - val refValue = with(thisRef) { - referee.lookup() - } as REF - - // Build the query for child entities - val query: suspend () -> SizedIterable = { - factory.find { reference eq refValue } - } - - // Execute query with caching if enabled - val result = if (cache) { - @Suppress("UNCHECKED_CAST") - (transaction.entityCache.getOrPutReferrers(reference, thisRef.id, query)) - } else { - query() - } - - // Store in entity's reference cache for out-of-transaction access - thisRef.storeReferenceInCache(reference, result) - - result + } else if (thisRef.hasInReferenceCache(reference)) { + val cached = thisRef.getReferenceFromCache(reference) + @Suppress("UNCHECKED_CAST") + when (cached) { + is SizedIterable<*> -> cached as SizedIterable + null -> emptySized() + else -> error("Cached referrer has unexpected type: ${cached::class}") } + } else { + error("No transaction in context, and referrers not in entity cache for $reference") + } + } else { + // Mirrors JDBC's implicit flush via `DaoEntityID.invokeOnNoValue` on `id.value` access. + if (thisRef.id._value == null) { + transaction.entityCache.flush() + } + + val referee = reference.referee!! + val refereeValue = with(thisRef) { referee.lookup() } + + val needsEntityIdUnwrap = reference.columnType !is EntityIDColumnType<*> && + referee.columnType is EntityIDColumnType<*> && refereeValue is EntityID<*> + + @Suppress("UNCHECKED_CAST") + val refValue = if (needsEntityIdUnwrap) refereeValue.value as REF else refereeValue as REF + + val query: suspend () -> SizedIterable = { + factory.find { reference eq refValue } + .orderBy(*orderByExpressions.toTypedArray()) + } + + val result = if (cache) { + @Suppress("UNCHECKED_CAST") + transaction.entityCache.getOrPutReferrers(reference, thisRef.id, query) + } else { + query() } + + thisRef.storeReferenceInCache(reference, result) + result } } + + /** Modifies this reference to sort entities based on multiple columns as specified in [order]. */ + infix fun orderBy(order: List, SortOrder>>) = this.also { + orderByExpressions.addAll(order) + } + + /** Modifies this reference to sort entities according to the specified [order]. */ + infix fun orderBy(order: Pair, SortOrder>) = orderBy(listOf(order)) + + /** Modifies this reference to sort entities by a column specified in [expression] using ascending order. */ + infix fun orderBy(expression: Expression<*>) = orderBy(listOf(expression to SortOrder.ASC)) + + /** Modifies this reference to sort entities based on multiple columns as specified in [order]. */ + fun orderBy(vararg order: Pair, SortOrder>) = orderBy(order.asList()) } diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt index f5fad5685e..9208f1a2d8 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt @@ -29,6 +29,10 @@ class OptionalSuspendReference, REF : Any>( } } +// TODO ALIGN_WITH_JDBC: `referencedOnSuspend` / `optionalReferencedOnSuspend` diverge from JDBC's +// `referencedOn` / `optionalReferencedOn`. The suffix is intentional today (R2DBC returns a +// suspend accessor while JDBC returns an entity directly), but a unified DSL across modules +// would drop the suffix. infix fun , REF : Any> R2dbcEntityClass.referencedOnSuspend( reference: Column ): SuspendReference { @@ -40,53 +44,3 @@ infix fun , REF : Any> R2dbcEntityClass { return OptionalSuspendReference(reference, this) } - -fun , ChildID : Any, Child : R2dbcEntity, REF> R2dbcEntityClass.referrersOnSuspend( - reference: Column, - cache: Boolean = true -): R2dbcReferrers { - return R2dbcReferrers(reference, this, cache) -} - -infix fun , ChildID : Any, Child : R2dbcEntity, REF> R2dbcEntityClass.referrersOnSuspend( - reference: Column -): R2dbcReferrers { - return referrersOnSuspend(reference, cache = true) -} - -fun , ChildID : Any, Child : R2dbcEntity, REF : Any> - R2dbcEntityClass.optionalReferrersOnSuspend( - reference: Column, - cache: Boolean = true - ): R2dbcReferrers { - return R2dbcReferrers(reference, this, cache) -} - -infix fun , ChildID : Any, Child : R2dbcEntity, REF : Any> - R2dbcEntityClass.optionalReferrersOnSuspend(reference: Column): R2dbcReferrers { - return optionalReferrersOnSuspend(reference, cache = true) -} - -infix fun , ChildID : Any, Child : R2dbcEntity, REF> - R2dbcEntityClass.backReferencedOnSuspend(reference: Column): R2dbcBackReference { - return R2dbcBackReference(reference, this) -} - -infix fun , ChildID : Any, Child : R2dbcEntity, REF> - R2dbcEntityClass.optionalBackReferencedOnSuspend(reference: Column): R2dbcOptionalBackReference { - return R2dbcOptionalBackReference(reference, this) -} - -/** - * Overload for referencing a non-nullable column as an optional back reference. - * - * The child entity's referencing column is required (non-nullable) but, from the parent - * side, the relationship is still optional: a child row may or may not exist. This mirrors - * JDBC's `optionalBackReferencedOn(Column)` overload. - */ -@Suppress("UNCHECKED_CAST") -@JvmName("optionalBackReferencedOnSuspendNonNullable") -infix fun , ChildID : Any, Child : R2dbcEntity, REF : Any> - R2dbcEntityClass.optionalBackReferencedOnSuspend(reference: Column): R2dbcOptionalBackReference { - return R2dbcOptionalBackReference(reference as Column, this) -} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt index db3f6a5699..819e2d0c3b 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt @@ -5,6 +5,7 @@ import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.EntityIDColumnType import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.eq @@ -85,18 +86,8 @@ class SuspendAccessor, REF : Any>( ?: (entity._readValues?.let { row -> row[reference] } as? REF) ?: error("Reference column ${reference.name} has no value for entity ${entity.id}") - val parentEntity = when { - reference.referee == factory.table.id -> { - @Suppress("UNCHECKED_CAST") - factory.findById(refValue as EntityID) - } - reference.referee?.table == factory.table -> { - @Suppress("UNCHECKED_CAST") - val refereeColumn = reference.referee!! as Column - factory.find { refereeColumn eq refValue }.singleOrNull() - } - else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") - } ?: error("Referenced entity not found for column ${reference.name} with value $refValue") + val parentEntity = lookupParentEntity(factory, reference, refValue) + ?: error("Referenced entity not found for column ${reference.name} with value $refValue") entity.storeReferenceInCache(reference, parentEntity) @@ -172,21 +163,41 @@ class OptionalSuspendAccessor, REF : Any>( return null } - val parentEntity = when { - reference.referee == factory.table.id -> { - @Suppress("UNCHECKED_CAST") - factory.findById(refValue as EntityID) - } - reference.referee?.table == factory.table -> { - @Suppress("UNCHECKED_CAST") - val refereeColumn = reference.referee!! as Column - factory.find { refereeColumn eq refValue }.singleOrNull() - } - else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") - } + val parentEntity = lookupParentEntity(factory, reference, refValue) entity.storeReferenceInCache(reference, parentEntity) return parentEntity } } + +/** + * Shared lookup used by [SuspendAccessor.invoke] and [OptionalSuspendAccessor.invoke]. + * + * Mirrors JDBC's `Reference.getValue` / `OptionalReference.getValue` logic from `Entity.kt`: + * + * - When the child column already stores an `EntityID` AND the referee is the parent's id, + * hit the cache-friendly `findById` path. + * - Otherwise the child column stores a raw value (e.g. `Column` referencing + * `Cities.id : Column>`, or a column referencing a non-id unique column). + * Unwrap the referee's column type — if it's `EntityIDColumnType` we need to compare + * against the inner `idColumn` (a raw `Column`) so `eq refValue` type-checks. + */ +@Suppress("UNCHECKED_CAST") +internal suspend fun > lookupParentEntity( + factory: R2dbcEntityClass, + reference: Column<*>, + refValue: Any +): Parent? { + val referee = reference.referee + ?: error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + + return when { + refValue is EntityID<*> && referee == factory.table.id -> + factory.findById(refValue as EntityID) + else -> { + val baseReferee = (referee.columnType as? EntityIDColumnType)?.idColumn ?: referee + factory.find { (baseReferee as Column) eq refValue }.singleOrNull() + } + } +} diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/InnerTableLink.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/InnerTableLink.kt index 521ecdacfb..b775c8c979 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/InnerTableLink.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/v1/dao/InnerTableLink.kt @@ -123,7 +123,7 @@ class InnerTableLink, ID : Any, Target : Entity< // linked entities updated val targetClass = (value.firstOrNull() ?: oldValue.firstOrNull())?.klass if (targetClass != null) { - existingIds.plus(targetIds).forEach { + (existingIds + targetIds).forEach { tx.registerChange(targetClass, it, EntityChangeType.Updated) } } diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt b/exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt similarity index 98% rename from exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt rename to exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt index 39dae7210a..60c0268fa7 100644 --- a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt +++ b/exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/DMLTestsData.kt @@ -12,6 +12,7 @@ import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase import org.jetbrains.exposed.v1.r2dbc.tests.TestDB import java.math.BigDecimal +@Suppress("MagicNumber") object DMLTestsData { object Cities : Table() { val id: Column = integer("cityId").autoIncrement() @@ -50,7 +51,7 @@ object DMLTestsData { } } -@Suppress("LongMethod") +@Suppress("LongMethod", "MagicNumber") fun R2dbcDatabaseTestsBase.withCitiesAndUsers( exclude: Collection = emptyList(), statement: suspend R2dbcTransaction.( @@ -152,6 +153,7 @@ fun R2dbcDatabaseTestsBase.withSales( } } +@Suppress("MagicNumber") private suspend fun DMLTestsData.Sales.insertSaleData() { insertSale(2018, 11, "tea", "550.10") insertSale(2018, 12, "coffee", "1500.25") @@ -171,6 +173,7 @@ private suspend fun DMLTestsData.Sales.insertSale(year: Int, month: Int, product } } +@Suppress("MagicNumber") fun R2dbcDatabaseTestsBase.withSalesAndSomeAmounts( excludeSettings: Collection = emptyList(), statement: suspend R2dbcTransaction.( diff --git a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/InsertTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/InsertTests.kt index e40ced9b90..3d6c9d2a2a 100644 --- a/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/InsertTests.kt +++ b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/InsertTests.kt @@ -31,6 +31,7 @@ import org.jetbrains.exposed.v1.r2dbc.tests.shared.expectException import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction import org.junit.jupiter.api.Assumptions import org.junit.jupiter.api.Test +import java.util.Random import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -185,7 +186,7 @@ class InsertTests : R2dbcDatabaseTestsBase() { } val generatedIds = users.batchInsert(userNamesWithCityIds) { (userName, cityId) -> - this[users.id] = java.util.Random().nextInt().toString().take(6) + this[users.id] = Random().nextInt().toString().take(6) this[users.name] = userName this[users.cityId] = cityId.toInt() } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/demo/dao/SamplesDao.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/demo/dao/SamplesDao.kt index d9f59d7678..60f1d075e4 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/demo/dao/SamplesDao.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/demo/dao/SamplesDao.kt @@ -9,10 +9,8 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import kotlin.test.Test object Users : IntIdTable() { @@ -81,7 +79,6 @@ fun main() { } } -@Tag(MISSING_R2DBC_TEST) class SamplesDao { @Test fun ensureSamplesDoesntCrash() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/EntityReferenceCacheTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/EntityReferenceCacheTest.kt index 075720cb00..2024584e2c 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/EntityReferenceCacheTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/EntityReferenceCacheTest.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.SizedCollection import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.demo.dao.Cities import org.jetbrains.exposed.v1.tests.demo.dao.City @@ -28,7 +27,6 @@ import org.jetbrains.exposed.v1.tests.shared.entities.VNumber import org.jetbrains.exposed.v1.tests.shared.entities.VString import org.jetbrains.exposed.v1.tests.shared.entities.ViaTestData import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.properties.Delegates import kotlin.test.assertEquals @@ -36,7 +34,6 @@ import kotlin.test.assertFails import kotlin.test.assertNotNull import kotlin.test.assertNull -@Tag(MISSING_R2DBC_TEST) class EntityReferenceCacheTest : DatabaseTestsBase() { private val db by lazy { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/MultiDatabaseEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/MultiDatabaseEntityTest.kt index ad077921ea..a7df304772 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/MultiDatabaseEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/h2/MultiDatabaseEntityTest.kt @@ -7,7 +7,6 @@ import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.inTopLevelTransaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.entities.EntityTestsData @@ -15,7 +14,6 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assumptions import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.sql.Connection import kotlin.properties.Delegates @@ -23,7 +21,6 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -@Tag(MISSING_R2DBC_TEST) class MultiDatabaseEntityTest { private val db1 by lazy { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheRefreshTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheRefreshTests.kt index de6857e1ff..273884c499 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheRefreshTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheRefreshTests.kt @@ -13,10 +13,8 @@ import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.inTopLevelTransaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.sql.Connection import kotlin.test.assertEquals @@ -30,7 +28,6 @@ import kotlin.test.assertEquals * * @see GitHub Issue #1527 */ -@Tag(MISSING_R2DBC_TEST) class EntityCacheRefreshTests : DatabaseTestsBase() { // Skip databases that don't support SELECT FOR UPDATE val excludedDbs = listOf(TestDB.SQLITE, TestDB.SQLSERVER) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheTests.kt index 0fde552705..967ed3cd0a 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityCacheTests.kt @@ -20,19 +20,16 @@ import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.sql.Connection.TRANSACTION_SERIALIZABLE import java.sql.SQLException import java.util.concurrent.atomic.AtomicInteger import kotlin.random.Random -@Tag(MISSING_R2DBC_TEST) class EntityCacheTests : DatabaseTestsBase() { object TestTable : IntIdTable("TestCache") { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityFieldWithTransformTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityFieldWithTransformTest.kt index 85af05bcb1..7774262aa4 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityFieldWithTransformTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityFieldWithTransformTest.kt @@ -7,15 +7,12 @@ import org.jetbrains.exposed.v1.dao.IntEntity import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.math.BigDecimal import kotlin.random.Random -@Tag(MISSING_R2DBC_TEST) class EntityFieldWithTransformTest : DatabaseTestsBase() { object TransformationsTable : IntIdTable() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityHookTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityHookTest.kt index 3858722047..7af10d6ce5 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityHookTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityHookTest.kt @@ -11,10 +11,8 @@ import org.jetbrains.exposed.v1.jdbc.SizedCollection import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test object EntityHookTestData { @@ -63,7 +61,6 @@ object EntityHookTestData { val allTables = arrayOf(Users, Cities, UsersToCities, Countries) } -@Tag(MISSING_R2DBC_TEST) class EntityHookTest : DatabaseTestsBase() { private fun trackChanges(statement: JdbcTransaction.() -> T): Triple, String> { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityWithBlobTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityWithBlobTests.kt index c5a3d742f2..ded0646360 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityWithBlobTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityWithBlobTests.kt @@ -8,15 +8,12 @@ import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.EntityClass import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.entities.EntityTestsData.YTable -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.* import kotlin.test.assertNull -@Tag(MISSING_R2DBC_TEST) class EntityWithBlobTests : DatabaseTestsBase() { object BlobTable : IdTable("YTable") { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ForeignIdEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ForeignIdEntityTest.kt index 01ed1df472..f498e2483d 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ForeignIdEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ForeignIdEntityTest.kt @@ -12,16 +12,13 @@ import org.jetbrains.exposed.v1.dao.LongEntity import org.jetbrains.exposed.v1.dao.LongEntityClass import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertFalse -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals /** * A case when a table's primary key is a foreign key to some other table (ProjectConfigs.id -> Project.id) */ -@Tag(MISSING_R2DBC_TEST) class ForeignIdEntityTest : DatabaseTestsBase() { object Schema { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/JavaUUIDTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/JavaUUIDTableEntityTest.kt index bf154102c9..fddc06a539 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/JavaUUIDTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/JavaUUIDTableEntityTest.kt @@ -10,9 +10,7 @@ import org.jetbrains.exposed.v1.dao.with import org.jetbrains.exposed.v1.jdbc.exists import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.UUID as JavaUUID @@ -66,7 +64,6 @@ object JavaUUIDTables { } } -@Tag(MISSING_R2DBC_TEST) class JavaUUIDTableEntityTest : DatabaseTestsBase() { @Test fun `create tables`() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/LongIdTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/LongIdTableEntityTest.kt index 21f086e791..e282b6679a 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/LongIdTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/LongIdTableEntityTest.kt @@ -9,9 +9,7 @@ import org.jetbrains.exposed.v1.dao.with import org.jetbrains.exposed.v1.jdbc.exists import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test object LongIdTables { @@ -49,7 +47,6 @@ object LongIdTables { } } -@Tag(MISSING_R2DBC_TEST) class LongIdTableEntityTest : DatabaseTestsBase() { @Test fun `create tables`() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/NonAutoIncEntities.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/NonAutoIncEntities.kt index 5162914c4e..8473b5a295 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/NonAutoIncEntities.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/NonAutoIncEntities.kt @@ -10,13 +10,10 @@ import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.concurrent.atomic.AtomicInteger -@Tag(MISSING_R2DBC_TEST) class NonAutoIncEntities : DatabaseTestsBase() { abstract class BaseNonAutoIncTable(name: String) : IdTable(name) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/OrderedReferenceTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/OrderedReferenceTest.kt index 83a0e2aa6b..e2663002bc 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/OrderedReferenceTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/OrderedReferenceTest.kt @@ -14,17 +14,14 @@ import org.jetbrains.exposed.v1.jdbc.JdbcTransaction import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull import kotlin.math.max -@Tag(MISSING_R2DBC_TEST) class OrderedReferenceTest : DatabaseTestsBase() { object Users : IntIdTable() @@ -206,7 +203,6 @@ class OrderedReferenceTest : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testOrderByWithEagerLoad() { withOrderedReferenceTestTables { // Clearing cache is not critical, just to be sure that there are no caches from diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/SelfReferenceTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/SelfReferenceTest.kt index ba6a95f45d..8c37d0f8f8 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/SelfReferenceTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/SelfReferenceTest.kt @@ -3,15 +3,12 @@ package org.jetbrains.exposed.v1.tests.shared.entities import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.jdbc.SchemaUtils -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.dml.DMLTestsData -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -@Tag(MISSING_R2DBC_TEST) class SelfReferenceTest { @Test diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UIntIdTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UIntIdTableEntityTest.kt index c824b748bf..0827bec390 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UIntIdTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UIntIdTableEntityTest.kt @@ -9,12 +9,9 @@ import org.jetbrains.exposed.v1.dao.with import org.jetbrains.exposed.v1.jdbc.exists import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test -@Tag(MISSING_R2DBC_TEST) class UIntIdTableEntityTest : DatabaseTestsBase() { @Test diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ULongIdTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ULongIdTableEntityTest.kt index 158feb81bd..7cd70e0cef 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ULongIdTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ULongIdTableEntityTest.kt @@ -9,12 +9,9 @@ import org.jetbrains.exposed.v1.dao.with import org.jetbrains.exposed.v1.jdbc.exists import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test -@Tag(MISSING_R2DBC_TEST) class ULongIdTableEntityTest : DatabaseTestsBase() { @Test diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UuidTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UuidTableEntityTest.kt index e5cbaff83e..017dd74396 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UuidTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/UuidTableEntityTest.kt @@ -9,11 +9,9 @@ import org.jetbrains.exposed.v1.dao.with import org.jetbrains.exposed.v1.jdbc.exists import org.jetbrains.exposed.v1.jdbc.insertAndGetId import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.versionNumber -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.uuid.Uuid @@ -83,7 +81,6 @@ object UuidTables { } } -@Tag(MISSING_R2DBC_TEST) class UuidTableEntityTest : DatabaseTestsBase() { @Test fun `create tables`() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ViaTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ViaTest.kt index 0afd4d5547..17f9114529 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ViaTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/ViaTest.kt @@ -14,11 +14,9 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.inTopLevelTransaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.sql.Connection import java.util.Objects @@ -79,7 +77,6 @@ class VString(id: EntityID) : Entity(id) { companion object : EntityClass(ViaTestData.StringsTable) } -@Tag(MISSING_R2DBC_TEST) class ViaTests : DatabaseTestsBase() { private fun VNumber.testWithBothTables(valuesToSet: List, body: (ViaTestData.IConnectionTable, List) -> Unit) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/WarmUpLinkedReferencesTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/WarmUpLinkedReferencesTests.kt index f9d3ae39b7..512a2ebbc1 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/WarmUpLinkedReferencesTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/WarmUpLinkedReferencesTests.kt @@ -5,12 +5,9 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.dao.IntEntity import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test -@Tag(MISSING_R2DBC_TEST) class WarmUpLinkedReferencesTests : DatabaseTestsBase() { object Box : IntIdTable() { From b1444e80d7f1e7949142f67b2bab556934212d87 Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Fri, 29 May 2026 14:53:51 +0200 Subject: [PATCH 5/7] feat: port JDBC DAO entity tests to R2DBC (part 2) --- .../v1/crypt/EncryptedColumnDaoTests.kt | 3 - exposed-dao-r2dbc-tests/build.gradle.kts | 6 + .../crypt/R2dbcEncryptedColumnDaoTests.kt | 89 ++++++ .../javatime/R2dbcJavatimeDefaultsTest.kt | 288 ++++++++++++++++++ .../jodatime/R2dbcJodaTimeDefaultTests.kt | 156 ++++++++++ .../dao/r2dbc/tests/json/JsonTestsData.kt | 38 +++ .../r2dbc/tests/json/R2bdcJsonBColumnTests.kt | 85 ++++++ .../r2dbc/tests/json/R2dbcJsonColumnTests.kt | 49 +++ .../R2dbcKotlinDatetimeDefaultsTest.kt | 145 +++++++++ .../dao/r2dbc/tests/shared/AliasesTests.kt | 50 +++ .../dao/r2dbc/tests/shared/R2dbcDDLTests.kt | 28 ++ .../R2dbcIdentifierManagerConcurrencyTest.kt | 59 ++++ .../tests/shared/ddl/R2dbcSequencesTests.kt | 54 ++++ .../dml/R2dbcColumnWithTransformTest.kt | 92 ++++++ .../tests/shared/dml/R2dbcInsertTests.kt | 88 ++++++ .../tests/shared/dml/R2dbcReturningTests.kt | 58 ++++ .../tests/shared/dml/R2dbcSelectTests.kt | 45 +++ .../shared/types/R2dbcArrayColumnTypeTests.kt | 63 ++++ .../types/R2dbcVectorColumnTypeTests.kt | 66 ++++ .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 64 ++-- .../exposed/v1/javatime/DefaultsTest.kt | 8 - .../v1/jodatime/JodaTimeDefaultsTest.kt | 6 - .../exposed/v1/json/JsonBColumnTests.kt | 4 - .../exposed/v1/json/JsonColumnTests.kt | 3 - .../exposed/v1/datetime/DefaultsTest.kt | 6 - .../exposed/v1/tests/shared/AliasesTests.kt | 4 - .../exposed/v1/tests/shared/CoroutineTests.kt | 4 +- .../exposed/v1/tests/shared/DDLTests.kt | 3 - .../IdentifierManagerConcurrencyTest.kt | 3 - .../v1/tests/shared/ddl/SequencesTests.kt | 3 - .../shared/dml/ColumnWithTransformTest.kt | 5 - .../v1/tests/shared/dml/InsertTests.kt | 3 - .../v1/tests/shared/dml/ReturningTests.kt | 3 - .../v1/tests/shared/dml/SelectTests.kt | 3 - .../shared/types/ArrayColumnTypeTests.kt | 3 - .../shared/types/VectorColumnTypeTests.kt | 3 - 36 files changed, 1501 insertions(+), 89 deletions(-) create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/crypt/R2dbcEncryptedColumnDaoTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/JsonTestsData.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2dbcJsonColumnTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/kotlindatetime/R2dbcKotlinDatetimeDefaultsTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/AliasesTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcDDLTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcIdentifierManagerConcurrencyTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/ddl/R2dbcSequencesTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcColumnWithTransformTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcInsertTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcReturningTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcSelectTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcArrayColumnTypeTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcVectorColumnTypeTests.kt diff --git a/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/EncryptedColumnDaoTests.kt b/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/EncryptedColumnDaoTests.kt index 0bba788f29..78a9b17c09 100644 --- a/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/EncryptedColumnDaoTests.kt +++ b/exposed-crypt/src/test/kotlin/org/jetbrains/exposed/v1/crypt/EncryptedColumnDaoTests.kt @@ -9,13 +9,10 @@ import org.jetbrains.exposed.v1.dao.entityCache import org.jetbrains.exposed.v1.jdbc.JdbcTransaction import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNotNull -@Tag(MISSING_R2DBC_TEST) class EncryptedColumnDaoTests : DatabaseTestsBase() { object TestTable : IntIdTable() { val varchar = encryptedVarchar("varchar", 100, Algorithms.AES_256_PBE_GCM("passwd", "12345678")) diff --git a/exposed-dao-r2dbc-tests/build.gradle.kts b/exposed-dao-r2dbc-tests/build.gradle.kts index 669e17844f..489627bda8 100644 --- a/exposed-dao-r2dbc-tests/build.gradle.kts +++ b/exposed-dao-r2dbc-tests/build.gradle.kts @@ -6,6 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") apply true + alias(libs.plugins.serialization) } kotlin { @@ -37,6 +38,11 @@ dependencies { implementation(project(":exposed-r2dbc")) implementation(project(":exposed-dao-r2dbc")) implementation(project(":exposed-r2dbc-tests")) + testImplementation(project(":exposed-java-time")) + testImplementation(project(":exposed-kotlin-datetime")) + testImplementation(project(":exposed-jodatime")) + testImplementation(project(":exposed-json")) + testImplementation(project(":exposed-crypt")) implementation(libs.slf4j) implementation(libs.log4j.slf4j.impl) diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/crypt/R2dbcEncryptedColumnDaoTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/crypt/R2dbcEncryptedColumnDaoTests.kt new file mode 100644 index 0000000000..d6c5e6106a --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/crypt/R2dbcEncryptedColumnDaoTests.kt @@ -0,0 +1,89 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.crypt + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.crypt.Algorithms +import org.jetbrains.exposed.v1.crypt.encryptedBinary +import org.jetbrains.exposed.v1.crypt.encryptedVarchar +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.assertNotNull +import kotlin.test.Test + +class R2dbcEncryptedColumnDaoTests : R2dbcDatabaseTestsBase() { + object TestTable : IntIdTable() { + val varchar = encryptedVarchar("varchar", 100, Algorithms.AES_256_PBE_GCM("passwd", "12345678")) + val binary = encryptedBinary("binary", 100, Algorithms.AES_256_PBE_GCM("passwd", "12345678")) + } + + class ETest(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(TestTable) + + var varchar by TestTable.varchar + var binary by TestTable.binary + } + + @Test + fun testEncryptedColumnsWithCachedEntities() { + val varcharValue = "varchar" + val binaryValue = "binary".toByteArray() + + fun R2dbcTransaction.assertNotNullWithCorrectFields(actualEntity: ETest?) { + assertNotNull(actualEntity) + assertEquals(varcharValue, actualEntity.varchar) + assertEquals(binaryValue.contentToString(), actualEntity.binary.contentToString()) + } + + withTables(TestTable) { + val entity = ETest.new { + varchar = varcharValue + binary = binaryValue + } + + // confirm new entity has been cached + assertNotNull(entityCache.find(ETest, entity.id)) + + // findById() should get cached entity without calling wrapRows() + val cachedEntity1 = ETest.findById(entity.id) + assertNotNullWithCorrectFields(cachedEntity1) + + // but find() should skip cache & call wrapRows() + val foundEntity1 = ETest.find { TestTable.id eq entity.id }.singleOrNull() + assertNotNullWithCorrectFields(foundEntity1) + + // DSL result passed to wrapRow() also skips the cache + TestTable.selectAll().first().let { + val foundEntity2 = ETest.wrapRow(it) + assertNotNullWithCorrectFields(foundEntity2) + } + } + } + + @Test + fun testEncryptedColumnsWithDao() { + withTables(TestTable) { + val varcharValue = "varchar" + val binaryValue = "binary".toByteArray() + + val entity = ETest.new { + varchar = varcharValue + binary = binaryValue + } + assertEquals(varcharValue, entity.varchar) + assertEquals(binaryValue, entity.binary) + + TestTable.selectAll().first().let { + assertEquals(varcharValue, it[TestTable.varchar]) + assertEquals(String(binaryValue), String(it[TestTable.binary])) + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt new file mode 100644 index 0000000000..dafdf53ed8 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt @@ -0,0 +1,288 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.javatime + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.CustomFunction +import org.jetbrains.exposed.v1.core.Table.PrimaryKey +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.vendors.H2Dialect +import org.jetbrains.exposed.v1.core.vendors.MariaDBDialect +import org.jetbrains.exposed.v1.core.vendors.MysqlDialect +import org.jetbrains.exposed.v1.core.vendors.OracleDialect +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import org.jetbrains.exposed.v1.core.vendors.SQLServerDialect +import org.jetbrains.exposed.v1.core.vendors.SQLiteDialect +import org.jetbrains.exposed.v1.javatime.CurrentDateTime +import org.jetbrains.exposed.v1.javatime.JavaOffsetDateTimeColumnType +import org.jetbrains.exposed.v1.javatime.datetime +import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.currentDialectTest +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.temporal.Temporal +import kotlin.random.Random.Default.nextInt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +private val dbTimestampNow: CustomFunction + get() = object : CustomFunction("now", JavaOffsetDateTimeColumnType()) {} + +class R2dbcJavatimeDefaultsTest : R2dbcDatabaseTestsBase() { + object TableWithDBDefault : IntIdTable() { + var cIndex = 0 + val field = varchar("field", 100) + val t1 = datetime("t1").defaultExpression(CurrentDateTime) + val clientDefault = integer("clientDefault").clientDefault { cIndex++ } + } + + class DBDefault(id: EntityID) : IntR2dbcEntity(id) { + var field by TableWithDBDefault.field + var t1 by TableWithDBDefault.t1 + var clientDefault by TableWithDBDefault.clientDefault + + override fun equals(other: Any?): Boolean { + return (other as? DBDefault)?.let { id == it.id && field == it.field && equalDateTime(t1, it.t1) } ?: false + } + + override fun hashCode(): Int = id.value.hashCode() + + companion object : IntR2dbcEntityClass(TableWithDBDefault) + } + + object DefaultTimestampTable : IntIdTable("test_table") { + val timestamp: Column = + timestampWithTimeZone("timestamp").defaultExpression(dbTimestampNow) + } + + class DefaultTimestampEntity(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(DefaultTimestampTable) + + var timestamp: OffsetDateTime by DefaultTimestampTable.timestamp + } + + @Test + fun testDefaultsWithExplicit01() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = LocalDateTime.now().minusDays(5) + } + ) + commit() + created.forEach { + DBDefault.removeFromCache(it) + } + + val entities = DBDefault.all().toList() + assertEqualCollections(created.map { it.id }, entities.map { it.id }) + } + } + + @Test + fun testDefaultsWithExplicit02() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { + field = "2" + t1 = LocalDateTime.now().minusDays(5) + }, + DBDefault.new { field = "1" } + ) + + flushCache() + // R2DBC: INSERT/RETURNING doesn't bring back `defaultExpression` columns (`t1`), and + // `Column.getValue` is non-suspend so it can't lazy-load like JDBC does. Refresh + // explicitly so `created[i].t1` (read by `equals`) has a value to compare. + created.forEach { it.refresh() } + created.forEach { DBDefault.removeFromCache(it) } + val entities = DBDefault.all().toList() + assertEqualCollections(created, entities) + } + } + + @Test + fun testDefaultsInvokedOnlyOncePerEntity() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + val db2 = DBDefault.new { field = "2" } + flushCache() + assertEquals(0, db1.clientDefault) + assertEquals(1, db2.clientDefault) + assertEquals(2, TableWithDBDefault.cIndex) + } + } + + @Test + fun testDefaultsCanBeOverridden() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + val db2 = DBDefault.new { field = "2" } + db1.clientDefault = 12345 + flushCache() + assertEquals(12345, db1.clientDefault) + assertEquals(1, db2.clientDefault) + assertEquals(2, TableWithDBDefault.cIndex) + + flushCache() + assertEquals(12345, db1.clientDefault) + } + } + + @Test + fun testCustomDefaultTimestampFunctionWithEntity() { + withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { + val entity = DefaultTimestampEntity.new {} + // R2DBC: `defaultExpression(dbTimestampNow)` is evaluated by the DB and isn't part of + // the INSERT's resultedValues, so `entity.timestamp` has no cached value yet. Flush and + // refresh so the row is loaded back from the DB (JDBC does this implicitly on read). + entity.refresh(flush = true) + + val timestamp = DefaultTimestampTable.selectAll().first()[DefaultTimestampTable.timestamp] + + assertEquals(timestamp, entity.timestamp) + } + } + + object TableWithDefaultValue : IdTable() { + const val DEFAULT_VALUE = 10 + val value = integer("value") + val valueWithDefault = integer("valueWithDefault").default(DEFAULT_VALUE) + + override val id = integer("id").clientDefault { nextInt() }.entityId() + override val primaryKey: PrimaryKey = PrimaryKey(id) + } + + class TableWithDefaultValueEntity(id: EntityID) : R2dbcEntity(id) { + var value by TableWithDefaultValue.value + var valueWithDefault by TableWithDefaultValue.valueWithDefault + + companion object : R2dbcEntityClass(TableWithDefaultValue) + } + + @Test + fun testExplicitInsertionOfDefaultValuesWithIdTable() { + withTables(TableWithDefaultValue) { + TableWithDefaultValueEntity.new(5) { + value = 94 + valueWithDefault = TableWithDefaultValue.DEFAULT_VALUE + }.run { + assertTrue(this.writeValues.values.contains(TableWithDefaultValue.DEFAULT_VALUE)) + } + } + } +} + +/** + * Duplicated from `exposed-java-time` module + */ +fun equalDateTime(d1: Temporal?, d2: Temporal?) = try { + assertEqualDateTime(d1, d2) + true +} catch (_: Exception) { + false +} + +/** + * Duplicated from `exposed-java-time` module + */ +fun assertEqualDateTime(d1: T?, d2: T?) { + when { + d1 == null && d2 == null -> return + d1 == null -> error("d1 is null while d2 is not on ${currentDialectTest.name}") + d2 == null -> error("d1 is not null while d2 is null on ${currentDialectTest.name}") + d1 is LocalTime && d2 is LocalTime -> { + assertEquals(d1.toSecondOfDay(), d2.toSecondOfDay(), "Failed on seconds ${currentDialectTest.name}") + if (d2.nano != 0) { + assertEqualFractionalPart(d1.nano, d2.nano) + } + } + d1 is LocalDateTime && d2 is LocalDateTime -> { + assertEquals( + d1.toEpochSecond(ZoneOffset.UTC), + d2.toEpochSecond(ZoneOffset.UTC), + "Failed on epoch seconds ${currentDialectTest.name}" + ) + assertEqualFractionalPart(d1.nano, d2.nano) + } + d1 is Instant && d2 is Instant -> { + assertEquals(d1.epochSecond, d2.epochSecond, "Failed on epoch seconds ${currentDialectTest.name}") + assertEqualFractionalPart(d1.nano, d2.nano) + } + d1 is OffsetDateTime && d2 is OffsetDateTime -> { + assertEqualDateTime(d1.toLocalDateTime(), d2.toLocalDateTime()) + assertEquals(d1.offset, d2.offset) + } + else -> assertEquals(d1, d2, "Failed on ${currentDialectTest.name}") + } +} + +/** + * Duplicated from `exposed-java-time` module + */ +private fun assertEqualFractionalPart(nano1: Int, nano2: Int) { + val dialect = currentDialectTest + val db = dialect.name + when (dialect) { + // accurate to 100 nanoseconds + is SQLServerDialect -> + assertEquals(roundTo100Nanos(nano1), roundTo100Nanos(nano2), "Failed on 1/10th microseconds $db") + // microseconds + is MariaDBDialect -> + assertEquals(floorToMicro(nano1), floorToMicro(nano2), "Failed on microseconds $db") + is H2Dialect, is PostgreSQLDialect, is MysqlDialect -> { + when ((dialect as? MysqlDialect)?.isFractionDateTimeSupported()) { + null, true -> { + assertEquals(roundToMicro(nano1), roundToMicro(nano2), "Failed on microseconds $db") + } + else -> {} // don't compare fractional part + } + } + // milliseconds + is OracleDialect -> + assertEquals(roundToMilli(nano1), roundToMilli(nano2), "Failed on milliseconds $db") + is SQLiteDialect -> + assertEquals(floorToMilli(nano1), floorToMilli(nano2), "Failed on milliseconds $db") + else -> fail("Unknown dialect $db") + } +} + +private fun roundTo100Nanos(nanos: Int): Int { + return BigDecimal(nanos).divide(BigDecimal(100), RoundingMode.HALF_UP).toInt() +} + +private fun roundToMicro(nanos: Int): Int { + return BigDecimal(nanos).divide(BigDecimal(1_000), RoundingMode.HALF_UP).toInt() +} + +private fun floorToMicro(nanos: Int): Int = nanos / 1_000 + +private fun roundToMilli(nanos: Int): Int { + return BigDecimal(nanos).divide(BigDecimal(1_000_000), RoundingMode.HALF_UP).toInt() +} + +private fun floorToMilli(nanos: Int): Int { + return nanos / 1_000_000 +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt new file mode 100644 index 0000000000..68a8131019 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt @@ -0,0 +1,156 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.jodatime + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.CustomFunction +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.jodatime.CurrentDate +import org.jetbrains.exposed.v1.jodatime.CurrentDateTime +import org.jetbrains.exposed.v1.jodatime.DateTimeWithTimeZoneColumnType +import org.jetbrains.exposed.v1.jodatime.date +import org.jetbrains.exposed.v1.jodatime.datetime +import org.jetbrains.exposed.v1.jodatime.timestampWithTimeZone +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.currentDialectTest +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +private val dbTimestampNow: CustomFunction + get() = object : CustomFunction("now", DateTimeWithTimeZoneColumnType()) {} + +class R2dbcJodaTimeDefaultTests : R2dbcDatabaseTestsBase() { + + object TableWithDBDefault : IntIdTable() { + var cIndex = 0 + val field = varchar("field", 100) + val t1 = datetime("t1").defaultExpression(CurrentDateTime) + val t2 = date("t2").defaultExpression(CurrentDate) + val clientDefault = integer("clientDefault").clientDefault { cIndex++ } + } + + class DBDefault(id: EntityID) : IntR2dbcEntity(id) { + var field by TableWithDBDefault.field + var t1 by TableWithDBDefault.t1 + var t2 by TableWithDBDefault.t2 + val clientDefault by TableWithDBDefault.clientDefault + + override fun equals(other: Any?): Boolean { + return (other as? DBDefault)?.let { + id == it.id && field == it.field && equalDateTime(t1, it.t1) && equalDateTime(t2, it.t2) + } ?: false + } + + override fun hashCode(): Int = id.value.hashCode() + + companion object : IntR2dbcEntityClass(TableWithDBDefault) + } + + @Test + fun testDefaultsWithExplicit01() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = DateTime.now().minusDays(5) + } + ) + flushCache() + created.forEach { + DBDefault.removeFromCache(it) + } + + val entities = DBDefault.all().toList() + assertEqualCollections(created.map { it.id }, entities.map { it.id }) + } + } + + @Test + fun testDefaultsWithExplicit02() { + // MySql 5 is excluded because it does not support `CURRENT_DATE()` as a default value + withTables(excludeSettings = listOf(TestDB.MYSQL_V5), TableWithDBDefault) { + val created = listOf( + DBDefault.new { + field = "2" + t1 = DateTime.now().minusDays(5) + }, + DBDefault.new { field = "1" } + ) + + flushCache() + // R2DBC: INSERT/RETURNING doesn't bring back `defaultExpression` columns (`t1`, `t2`), + // and `Column.getValue` is non-suspend so it can't lazy-load like JDBC does. Refresh + // explicitly so `created[i].t1`/`t2` (read by `equals`) have values to compare. + created.forEach { it.refresh() } + created.forEach { DBDefault.removeFromCache(it) } + val entities = DBDefault.all().toList() + assertEqualCollections(created, entities) + } + } + + @Test + fun testDefaultsInvokedOnlyOncePerEntity() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + val db2 = DBDefault.new { field = "2" } + flushCache() + assertEquals(0, db1.clientDefault) + assertEquals(1, db2.clientDefault) + assertEquals(2, TableWithDBDefault.cIndex) + } + } + + object DefaultTimestampTable : IntIdTable("test_table") { + val timestamp: Column = + timestampWithTimeZone("timestamp").defaultExpression(dbTimestampNow) + } + + class DefaultTimestampEntity(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(DefaultTimestampTable) + + var timestamp: DateTime by DefaultTimestampTable.timestamp + } + + @Test + fun testCustomDefaultTimestampFunctionWithEntity() { + withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { + val entity = DefaultTimestampEntity.new {} + // R2DBC: `defaultExpression(dbTimestampNow)` is evaluated by the DB and isn't part of + // the INSERT's resultedValues, so `entity.timestamp` has no cached value yet. Flush and + // refresh so the row is loaded back from the DB (JDBC does this implicitly on read). + entity.refresh(flush = true) + + val timestamp = DefaultTimestampTable.selectAll().first()[DefaultTimestampTable.timestamp] + + assertEquals(timestamp, entity.timestamp) + } + } +} + +fun assertEqualDateTime(d1: DateTime?, d2: DateTime?) { + when { + d1 == null && d2 == null -> return + d1 == null -> error("d1 is null while d2 is not on ${currentDialectTest.name}") + d2 == null -> error("d1 is not null while d2 is null on ${currentDialectTest.name}") + else -> assertEquals(d1.millis, d2.millis, "Failed on ${currentDialectTest.name}") + } +} + +fun equalDateTime(d1: DateTime?, d2: DateTime?) = try { + assertEqualDateTime(d1, d2) + true +} catch (_: Exception) { + false +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/JsonTestsData.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/JsonTestsData.kt new file mode 100644 index 0000000000..95182a0fd1 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/JsonTestsData.kt @@ -0,0 +1,38 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.json + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.json.json +import org.jetbrains.exposed.v1.json.jsonb + +object JsonTestsData { + object JsonTable : IntIdTable("j_table") { + val jsonColumn = json("j_column", Json.Default) + } + + object JsonBTable : IntIdTable("j_b_table") { + val jsonBColumn = jsonb("j_b_column", Json.Default) + } + + class JsonEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(JsonTable) + + var jsonColumn by JsonTable.jsonColumn + } + + class JsonBEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(JsonBTable) + + var jsonBColumn by JsonBTable.jsonBColumn + } +} + +@Serializable +data class DataHolder(val user: User, val logins: Int, val active: Boolean, val team: String?) + +@Serializable +data class User(val name: String, val team: String?) diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt new file mode 100644 index 0000000000..c7091c3dea --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt @@ -0,0 +1,85 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.json + +import kotlinx.coroutines.flow.single +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.IntegerColumnType +import org.jetbrains.exposed.v1.core.castTo +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.greaterEq +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import org.jetbrains.exposed.v1.json.extract +import org.jetbrains.exposed.v1.json.json +import org.jetbrains.exposed.v1.json.jsonb +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.currentDialectTest +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.update +import org.junit.jupiter.api.Test + +class R2bdcJsonBColumnTests : R2dbcDatabaseTestsBase() { + private val binaryJsonNotSupportedDB = listOf(TestDB.SQLSERVER, TestDB.ORACLE) + + @Test + fun testDAOFunctionsWithJsonBColumn() { + val dataTable = JsonTestsData.JsonBTable + val dataEntity = JsonTestsData.JsonBEntity + + withTables(excludeSettings = binaryJsonNotSupportedDB, dataTable) { testDb -> + 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) + + if (testDb !in TestDB.ALL_H2_V2) { + dataEntity.new { jsonBColumn = dataA } + val loginCount = if (currentDialectTest is PostgreSQLDialect) { + JsonTestsData.JsonBTable.jsonBColumn.extract("logins").castTo(IntegerColumnType()) + } else { + JsonTestsData.JsonBTable.jsonBColumn.extract(".logins") + } + val frequentUser = dataEntity.find { loginCount greaterEq 50 }.single() + assertEquals(updatedUser, frequentUser.jsonBColumn) + } + } + } + + object MyTable : IntIdTable("my_table") { + val name = text("name") + val user = jsonb("json_column", Json.Default) + } + + class MyEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(MyTable) + + var name by MyTable.name + var user by MyTable.user + } + + @Test + fun testFieldsOutsideTransaction() { + lateinit var entity: MyEntity + withTables(excludeSettings = binaryJsonNotSupportedDB, MyTable) { + entity = MyEntity.new { + name = "Test" + user = User("Pro", "Alpha") + } + } + + // Should be able to read fields despite having no transaction + kotlin.test.assertEquals("Test", entity.name) + kotlin.test.assertEquals(User("Pro", "Alpha"), entity.user) + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2dbcJsonColumnTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2dbcJsonColumnTests.kt new file mode 100644 index 0000000000..9c7817101a --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/json/R2dbcJsonColumnTests.kt @@ -0,0 +1,49 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.json + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.vendors.PostgreSQLDialect +import org.jetbrains.exposed.v1.json.extract +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.currentDialectTest +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.update +import kotlin.test.Test + +class R2dbcJsonColumnTests : R2dbcDatabaseTestsBase() { + @Test + fun testDAOFunctionsWithJsonColumn() { + val dataTable = JsonTestsData.JsonTable + val dataEntity = JsonTestsData.JsonEntity + + withTables(dataTable) { testDb -> + 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.ALL_H2_V2) { + dataEntity.new { jsonColumn = dataA } + val path = if (currentDialectTest is PostgreSQLDialect) { + arrayOf("user", "team") + } else { + arrayOf(".user.team") + } + val userTeam = JsonTestsData.JsonTable.jsonColumn.extract(*path) + val userInTeamB = dataEntity.find { userTeam like "B%" }.single() + + assertEquals(updatedUser, userInTeamB.jsonColumn) + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/kotlindatetime/R2dbcKotlinDatetimeDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/kotlindatetime/R2dbcKotlinDatetimeDefaultsTest.kt new file mode 100644 index 0000000000..f842924ce9 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/kotlindatetime/R2dbcKotlinDatetimeDefaultsTest.kt @@ -0,0 +1,145 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.kotlindatetime + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.CustomFunction +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.datetime.CurrentDate +import org.jetbrains.exposed.v1.datetime.CurrentDateTime +import org.jetbrains.exposed.v1.datetime.KotlinOffsetDateTimeColumnType +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.datetime +import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime +import kotlin.time.Clock +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +private val dbTimestampNow: CustomFunction + get() = object : CustomFunction("now", KotlinOffsetDateTimeColumnType()) {} + +class R2dbcKotlinDatetimeDefaultsTest : R2dbcDatabaseTestsBase() { + + private fun localDateTimeNowMinusUnit(value: Int, unit: DurationUnit) = + Clock.System.now().minus(value.toDuration(unit)).toLocalDateTime(TimeZone.currentSystemDefault()) + + object TableWithDBDefault : IntIdTable() { + var cIndex = 0 + val field = varchar("field", 100) + val t1 = datetime("t1").defaultExpression(CurrentDateTime) + val t2 = date("t2").defaultExpression(CurrentDate) + val clientDefault = integer("clientDefault").clientDefault { cIndex++ } + } + + class DBDefault(id: EntityID) : IntR2dbcEntity(id) { + var field by TableWithDBDefault.field + var t1 by TableWithDBDefault.t1 + var t2 by TableWithDBDefault.t2 + val clientDefault by TableWithDBDefault.clientDefault + + override fun equals(other: Any?): Boolean { + return (other as? DBDefault)?.let { id == it.id && field == it.field && t1 == it.t1 && t2 == it.t2 } ?: false + } + + override fun hashCode(): Int = id.value.hashCode() + + companion object : IntR2dbcEntityClass(TableWithDBDefault) + } + + @Test + fun testDefaultsWithExplicit01() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = localDateTimeNowMinusUnit(5, DurationUnit.DAYS) + } + ) + commit() + created.forEach { + DBDefault.removeFromCache(it) + } + + val entities = DBDefault.all().toList() + assertEqualCollections(created.map { it.id }, entities.map { it.id }) + } + } + + @Test + fun testDefaultsWithExplicit02() { + // MySql 5 is excluded because it does not support `CURRENT_DATE()` as a default value + withTables(excludeSettings = listOf(TestDB.MYSQL_V5), TableWithDBDefault) { + val created = listOf( + DBDefault.new { + field = "2" + t1 = localDateTimeNowMinusUnit(5, DurationUnit.DAYS) + }, + DBDefault.new { field = "1" } + ) + + flushCache() + // R2DBC: INSERT/RETURNING doesn't bring back `defaultExpression` columns (`t1`, `t2`), + // and `Column.getValue` is non-suspend so it can't lazy-load like JDBC does. Refresh + // explicitly so `created[i].t1`/`t2` (read by `equals`) have values to compare. + created.forEach { it.refresh() } + created.forEach { DBDefault.removeFromCache(it) } + val entities = DBDefault.all().toList() + assertEqualCollections(created, entities) + } + } + + @Test + fun testDefaultsInvokedOnlyOncePerEntity() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + val db2 = DBDefault.new { field = "2" } + flushCache() + assertEquals(0, db1.clientDefault) + assertEquals(1, db2.clientDefault) + assertEquals(2, TableWithDBDefault.cIndex) + } + } + + object DefaultTimestampTable : IntIdTable("test_table") { + val timestamp: Column = + timestampWithTimeZone("timestamp").defaultExpression(dbTimestampNow) + } + + class DefaultTimestampEntity(id: EntityID) : R2dbcEntity(id) { + companion object : R2dbcEntityClass(DefaultTimestampTable) + + var timestamp: OffsetDateTime by DefaultTimestampTable.timestamp + } + + @Test + fun testCustomDefaultTimestampFunctionWithEntity() { + withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { + val entity = DefaultTimestampEntity.new {} + // R2DBC: `defaultExpression(dbTimestampNow)` is evaluated by the DB and isn't part of + // the INSERT's resultedValues, so `entity.timestamp` has no cached value yet. Flush and + // refresh so the row is loaded back from the DB (JDBC does this implicitly on read). + entity.refresh(flush = true) + + val timestamp = DefaultTimestampTable.selectAll().first()[DefaultTimestampTable.timestamp] + + assertEquals(timestamp, entity.timestamp) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/AliasesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/AliasesTests.kt new file mode 100644 index 0000000000..a09d21bcff --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/AliasesTests.kt @@ -0,0 +1,50 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.assertNotNull +import kotlin.test.Test + +class AliasesTests : R2dbcDatabaseTestsBase() { + @Test + fun testWrapRowWithAliasedTable() { + withTables(EntityTestsData.XTable, EntityTestsData.YTable) { + val entity1 = EntityTestsData.XEntity.new { + this.b1 = false + } + + flushCache() + entityCache.clear() + + val alias = EntityTestsData.XTable.alias("xAlias") + val entityFromAlias = alias.selectAll().map { EntityTestsData.XEntity.wrapRow(it, alias) }.singleOrNull() + assertNotNull(entityFromAlias) + assertEquals(entity1.id, entityFromAlias.id) + assertEquals(false, entityFromAlias.b1) + } + } + + @Test + fun testWrapRowWithAliasedQuery() { + withTables(EntityTestsData.XTable, EntityTestsData.YTable) { + val entity1 = EntityTestsData.XEntity.new { + this.b1 = false + } + + flushCache() + entityCache.clear() + + val alias = EntityTestsData.XTable.selectAll().alias("xAlias") + val entityFromAlias = alias.selectAll().map { EntityTestsData.XEntity.wrapRow(it, alias) }.singleOrNull() + assertNotNull(entityFromAlias) + assertEquals(entity1.id, entityFromAlias.id) + assertEquals(false, entityFromAlias.b1) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcDDLTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcDDLTests.kt new file mode 100644 index 0000000000..8adb03f8a6 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcDDLTests.kt @@ -0,0 +1,28 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import kotlin.test.Test + +class R2dbcDDLTests : R2dbcDatabaseTestsBase() { + + object KeyWordTable : IntIdTable(name = "keywords") { + val bool = bool("bool") + } + + @Test + fun testDropTableFlushesCache() { + class Keyword(id: EntityID) : IntR2dbcEntity(id) { + var bool by KeyWordTable.bool + } + + val keywordEntityClass = object : IntR2dbcEntityClass(KeyWordTable, Keyword::class.java) {} + + withTables(KeyWordTable) { + keywordEntityClass.new { bool = true } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcIdentifierManagerConcurrencyTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcIdentifierManagerConcurrencyTest.kt new file mode 100644 index 0000000000..50408f94ed --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcIdentifierManagerConcurrencyTest.kt @@ -0,0 +1,59 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.v1.core.statements.api.IdentifierManagerApi +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.junit.jupiter.api.Test +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.assertTrue + +class R2dbcIdentifierManagerConcurrencyTest : R2dbcDatabaseTestsBase() { + @Test + fun identifierManagerCachesSurviveConcurrentResolution() { + withDb { testDb -> + val manager: IdentifierManagerApi = db.identifierManager + // Prime the `keywords` / `shouldPreserveKeywordCasing` lazies from this transaction so + // worker threads can safely read them without a transaction context. + manager.needQuotes("warmup") + manager.shouldQuoteIdentifier("warmup") + manager.inProperCase("warmup") + manager.quoteIfNecessary("warmup") + + // Use unique keys per task so every worker is populating the cache (not just reading + // already-cached entries). A read-only workload never races on `put`, so the original + // LinkedHashMap-based cache would appear safe; to reliably reproduce #1704 the caches + // must be under constant mutation pressure. + val pool = Executors.newFixedThreadPool(16) + val errors = ConcurrentLinkedQueue() + try { + val futures = (0 until 2000).map { taskId -> + pool.submit { + try { + repeat(200) { i -> + val id = "col_${taskId}_$i" + manager.needQuotes(id) + manager.inProperCase(id) + manager.quoteIfNecessary(id) + manager.shouldQuoteIdentifier(id) + // Tokens containing `-` are not valid unquoted identifiers, so + // `quoteTokenIfNecessary` routes them through `quote(...)` and + // populates `quotedIdentifiersCache` — exercise the 5th cache. + manager.quoteIfNecessary("col-$taskId-$i") + } + } catch (t: Throwable) { + errors += t + } + } + } + futures.forEach { it.get(120, TimeUnit.SECONDS) } + } finally { + pool.shutdownNow() + } + assertTrue( + errors.isEmpty(), + "Expected no concurrency errors on $testDb, got ${errors.size}: ${errors.firstOrNull()}" + ) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/ddl/R2dbcSequencesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/ddl/R2dbcSequencesTests.kt new file mode 100644 index 0000000000..5937b165e4 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/ddl/R2dbcSequencesTests.kt @@ -0,0 +1,54 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.ddl + +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.UuidR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.UuidTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import org.junit.jupiter.api.Assumptions +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.uuid.Uuid + +class R2dbcSequencesTests : R2dbcDatabaseTestsBase() { + object TesterTable : UuidTable("Tester") { + val index = integer("index").autoIncrement() + val name = text("name") + } + + class TesterEntity(id: EntityID) : UuidR2dbcEntity(id) { + companion object : UuidR2dbcEntityClass(TesterTable) + + var index by TesterTable.index + var name by TesterTable.name + } + + @Test + fun testAutoIncrementColumnAccessWithEntity() = runTest { + Assumptions.assumeTrue(TestDB.POSTGRESQL in TestDB.enabledDialects()) + + TestDB.POSTGRESQL.connect() + + try { + suspendTransaction { + SchemaUtils.create(TesterTable) + } + + val testerEntity = suspendTransaction { + TesterEntity.new { + name = "test row" + } + } + + assertEquals(1, testerEntity.index) + } finally { + suspendTransaction { + SchemaUtils.drop(TesterTable) + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcColumnWithTransformTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcColumnWithTransformTest.kt new file mode 100644 index 0000000000..688056693a --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcColumnWithTransformTest.kt @@ -0,0 +1,92 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.v1.core.ColumnTransformer +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test + +class R2dbcColumnWithTransformTest : R2dbcDatabaseTestsBase() { + data class TransformDataHolder(val value: Int) + + class DataHolderTransformer : ColumnTransformer { + override fun unwrap(value: TransformDataHolder): Int = value.value + override fun wrap(value: Int): TransformDataHolder = TransformDataHolder(value) + } + + object TransformTable : IntIdTable("transform_table") { + val simple = integer("simple") + .default(1) + .transform(DataHolderTransformer()) + val chained = varchar("chained", length = 128) + .transform(wrap = { it.toInt() }, unwrap = { it.toString() }) + .transform(DataHolderTransformer()) + .default(TransformDataHolder(2)) + } + + class TransformEntity(id: EntityID) : IntR2dbcEntity(id) { + var simple by TransformTable.simple + var chained by TransformTable.chained + + companion object : IntR2dbcEntityClass(TransformTable) + } + + @Test + fun testTransformedValuesWithDAO() { + withTables(TransformTable) { + val entity = TransformEntity.new { + this.simple = TransformDataHolder(120) + this.chained = TransformDataHolder(240) + } + + val row = TransformTable.selectAll().first() + assertEquals(TransformDataHolder(120), row[TransformTable.simple]) + assertEquals(TransformDataHolder(240), row[TransformTable.chained]) + + assertEquals(TransformDataHolder(120), entity.simple) + assertEquals(TransformDataHolder(240), entity.chained) + } + } + + @Test + fun testEntityWithDefaultValue() { + withTables(TransformTable) { + val entity = TransformEntity.new {} + + assertEquals(TransformDataHolder(1), entity.simple) + assertEquals(TransformDataHolder(2), entity.chained) + + val entry = TransformTable.selectAll().first() + + assertEquals(1, entry[TransformTable.simple].value) + assertEquals(2, entry[TransformTable.chained].value) + } + } + + @Test + fun testWrapRowWithAliases() { + withTables(TransformTable) { + TransformEntity.new { + simple = TransformDataHolder(10) + } + entityCache.clear() + + val tableAlias = TransformTable.alias("table_alias") + val e2 = tableAlias.selectAll().map { TransformEntity.wrapRow(it, tableAlias) }.first() + assertEquals(10, e2.simple.value) + entityCache.clear() + + val queryAlias = TransformTable.selectAll().alias("query_alias") + val e3 = queryAlias.selectAll().map { TransformEntity.wrapRow(it, queryAlias) }.first() + assertEquals(10, e3.simple.value) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcInsertTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcInsertTests.kt new file mode 100644 index 0000000000..138e9a1403 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcInsertTests.kt @@ -0,0 +1,88 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.dao.r2dbc.tests.shared.R2dbcEntityTests +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.deleteWhere +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.junit.jupiter.api.Test +import kotlin.test.assertNull +import kotlin.uuid.Uuid + +class R2dbcInsertTests : R2dbcDatabaseTestsBase() { + private object OrderedDataTable : IntIdTable() { + val name = text("name") + val order = integer("order") + } + + class OrderedData(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(OrderedDataTable) + + var name by OrderedDataTable.name + var order by OrderedDataTable.order + } + + @Test + fun testInsertWithColumnNamedWithKeyword() { + withTables(OrderedDataTable) { + val foo = OrderedData.new { + name = "foo" + order = 20 + } + val bar = OrderedData.new { + name = "bar" + order = 10 + } + + assertEqualLists(listOf(bar, foo), OrderedData.all().orderBy(OrderedDataTable.order to SortOrder.ASC).toList()) + } + } + + @Test + fun testOptReferenceAllowsNullValues() { + withTables(R2dbcEntityTests.Posts) { + val id1 = R2dbcEntityTests.Posts.insertAndGetId { + it[board] = null + it[category] = null + } + + val inserted1 = R2dbcEntityTests.Posts.selectAll().where { R2dbcEntityTests.Posts.id eq id1 }.single() + assertNull(inserted1[R2dbcEntityTests.Posts.board]) + assertNull(inserted1[R2dbcEntityTests.Posts.category]) + + val boardId = R2dbcEntityTests.Boards.insertAndGetId { + it[name] = Uuid.random().toString() + } + val categoryId = R2dbcEntityTests.Categories.insert { + it[title] = "Category" + }[R2dbcEntityTests.Categories.uniqueId] + + val id2 = R2dbcEntityTests.Posts.insertAndGetId { + it[board] = Op.nullOp() + it[category] = categoryId + it[board] = boardId.value + } + + R2dbcEntityTests.Posts.deleteWhere { R2dbcEntityTests.Posts.id eq id2 } + + val nullableCategoryID: Uuid? = categoryId + val nullableBoardId: Int? = boardId.value + R2dbcEntityTests.Posts.insertAndGetId { + it[board] = Op.nullOp() + it[category] = nullableCategoryID + it[board] = nullableBoardId + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcReturningTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcReturningTests.kt new file mode 100644 index 0000000000..80da87874f --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcReturningTests.kt @@ -0,0 +1,58 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.upsertReturning +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class R2dbcReturningTests : R2dbcDatabaseTestsBase() { + private val updateReturningSupportedDb = TestDB.ALL_POSTGRES.toSet() + private val returningSupportedDb = updateReturningSupportedDb + TestDB.MARIADB + + object Items : IntIdTable("items") { + val name = varchar("name", 32) + val price = double("price") + } + + class ItemDAO(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Items) + + var name by Items.name + var price by Items.price + } + + @Test + fun testUpsertReturningWithDAO() { + withTables(TestDB.ALL - returningSupportedDb, Items) { + val result1 = Items.upsertReturning { + it[name] = "A" + it[price] = 99.0 + }.let { + ItemDAO.wrapRow(it.single()) + } + assertEquals(1, result1.id.value) + assertEquals("A", result1.name) + assertEquals(99.0, result1.price) + + val result2 = Items.upsertReturning { + it[id] = 1 + it[name] = "B" + it[price] = 200.0 + }.let { + ItemDAO.wrapRow(it.single()) + } + assertEquals(1, result2.id.value) + assertEquals("B", result2.name) + assertEquals(200.0, result2.price) + + assertEquals(1, Items.selectAll().count()) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcSelectTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcSelectTests.kt new file mode 100644 index 0000000000..863aecf112 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/dml/R2dbcSelectTests.kt @@ -0,0 +1,45 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.dao.r2dbc.tests.shared.R2dbcEntityTests +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.notInList +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.Test +import kotlin.test.assertNull + +class R2dbcSelectTests : R2dbcDatabaseTestsBase() { + @Test + fun testInListWithEntityIDColumns() { + withTables(R2dbcEntityTests.Posts, R2dbcEntityTests.Boards, R2dbcEntityTests.Categories) { + val board1 = R2dbcEntityTests.Board.new { + this.name = "Board1" + } + + val post1 = R2dbcEntityTests.Post.new { + this.board set board1 + } + + R2dbcEntityTests.Post.new { + category set R2dbcEntityTests.Category.new { title = "Category1" } + } + + val result1 = R2dbcEntityTests.Posts.selectAll().where { + R2dbcEntityTests.Posts.board inList listOf(board1.id) + }.singleOrNull()?.get(R2dbcEntityTests.Posts.id) + assertEquals(post1.id, result1) + + val result2 = R2dbcEntityTests.Board.find { + R2dbcEntityTests.Boards.id inList listOf(1, 2, 3, 4, 5) + }.singleOrNull() + assertEquals(board1, result2) + + val result3 = R2dbcEntityTests.Board.find { + R2dbcEntityTests.Boards.id notInList listOf(1, 2, 3, 4, 5) + }.singleOrNull() + assertNull(result3) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcArrayColumnTypeTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcArrayColumnTypeTests.kt new file mode 100644 index 0000000000..d61dcb90ec --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcArrayColumnTypeTests.kt @@ -0,0 +1,63 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.types + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.BinaryColumnType +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.TextColumnType +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import org.junit.jupiter.api.Test +import kotlin.test.assertContentEquals + +class R2dbcArrayColumnTypeTests : R2dbcDatabaseTestsBase() { + private val arrayTypeUnsupportedDb = TestDB.ALL - (TestDB.ALL_POSTGRES + TestDB.H2_V2 + TestDB.H2_V2_PSQL).toSet() + + object ArrayTestTable : IntIdTable("array_test_table") { + val numbers = array("numbers").default(listOf(5)) + val strings = array("strings", TextColumnType()).default(emptyList()) + val doubles = array("doubles").nullable() + val byteArray = array("byte_array", BinaryColumnType(32)).nullable() + } + + class ArrayTestDao(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(ArrayTestTable) + + var numbers by ArrayTestTable.numbers + var strings by ArrayTestTable.strings + var doubles by ArrayTestTable.doubles + } + + @Test + fun testArrayColumnWithDAOFunctions() { + withTestTableAndExcludeSettings { + val numInput = listOf(1, 2, 3) + val entity1 = ArrayTestDao.new { + numbers = numInput + doubles = null + } + assertContentEquals(numInput, entity1.numbers) + assertTrue(entity1.strings.isEmpty()) + + val doublesInput = listOf(9.0) + entity1.doubles = doublesInput + + assertContentEquals(doublesInput, ArrayTestDao.all().single().doubles) + } + } + + private fun withTestTableAndExcludeSettings( + vararg tables: Table = arrayOf(ArrayTestTable), + excludeSettings: Collection = arrayTypeUnsupportedDb, + statement: suspend R2dbcTransaction.(TestDB) -> Unit + ) { + withTables(excludeSettings = excludeSettings, *tables) { db -> + statement(db) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcVectorColumnTypeTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcVectorColumnTypeTests.kt new file mode 100644 index 0000000000..5eaa294ad0 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/types/R2dbcVectorColumnTypeTests.kt @@ -0,0 +1,66 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared.types + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.Test +import kotlin.math.abs +import kotlin.test.assertTrue + +class R2dbcVectorColumnTypeTests : R2dbcDatabaseTestsBase() { + private val vectorTypeSupportedDb = setOf(TestDB.ORACLE, TestDB.MARIADB, TestDB.POSTGRESQL, TestDB.SQLSERVER) + + object VectorEntityTable : IntIdTable("vector_tester") { + val embedding = vector("embedding", dimensions = 5) + } + + class VectorEntity(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(VectorEntityTable) + + var embedding by VectorEntityTable.embedding + } + + @Test + fun testVectorTypeWithDAO() { + withDb(vectorTypeSupportedDb) { testDb -> + try { + if (testDb == TestDB.POSTGRESQL) { + exec("CREATE EXTENSION IF NOT EXISTS vector;") + } + SchemaUtils.create(VectorEntityTable) + + val ve = VectorEntity.new { + embedding = floatArrayOf(0f, 1f, 0f, 0f, 0f) + } + + val inserted = VectorEntity.all().single() + assertEquals(ve.embedding, inserted.embedding) + + ve.embedding = floatArrayOf(1f, 0f, 0f, 0f, 0f) + ve.flush() + + val updated = VectorEntity.all().single().embedding + assertTargetWithinTolerance(updated) + } finally { + SchemaUtils.drop(VectorEntityTable) + if (testDb == TestDB.POSTGRESQL) { + exec("DROP EXTENSION IF EXISTS vector CASCADE;") + } + } + } + } + + private fun assertTargetWithinTolerance(actual: FloatArray, target: FloatArray = floatArrayOf(1f, 0f, 0f), tolerance: Double = 1e-6) { + assertTrue( + abs(actual[0] - target[0]) < tolerance && + abs(actual[1] - target[1]) < tolerance && + abs(actual[2] - target[2]) < tolerance + ) + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt index 22111b3cd2..61667f91ae 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -1,9 +1,7 @@ package org.jetbrains.exposed.r2dbc.dao -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.forEach import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.flow.toList @@ -11,34 +9,15 @@ import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcBackReference import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcOptionalBackReference import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcReferrers -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.ColumnSet -import org.jetbrains.exposed.v1.core.ColumnTransformer -import org.jetbrains.exposed.v1.core.ColumnWithTransform -import org.jetbrains.exposed.v1.core.Expression -import org.jetbrains.exposed.v1.core.ExpressionWithColumnType -import org.jetbrains.exposed.v1.core.Join -import org.jetbrains.exposed.v1.core.JoinType -import org.jetbrains.exposed.v1.core.Op -import org.jetbrains.exposed.v1.core.ResultRow -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.columnTransformer -import org.jetbrains.exposed.v1.core.count +import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable -import org.jetbrains.exposed.v1.core.eq -import org.jetbrains.exposed.v1.core.inList -import org.jetbrains.exposed.v1.r2dbc.Query -import org.jetbrains.exposed.v1.r2dbc.SizedCollection -import org.jetbrains.exposed.v1.r2dbc.SizedIterable -import org.jetbrains.exposed.v1.r2dbc.emptySized -import org.jetbrains.exposed.v1.r2dbc.mapLazy -import org.jetbrains.exposed.v1.r2dbc.select -import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.* import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager import kotlin.reflect.KFunction import kotlin.reflect.full.primaryConstructor +@Suppress("TooManyFunctions") abstract class R2dbcEntityClass>( val table: IdTable, entityType: Class? = null, @@ -220,6 +199,43 @@ abstract class R2dbcEntityClass>( return entity } + fun wrapRow(row: ResultRow, alias: Alias>): T { + require(alias.delegate == table) { "Alias for a wrong table ${alias.delegate.tableName} while ${table.tableName} expected" } + val newFieldsMapping = row.fieldIndex.mapNotNull { (exp, _) -> + val column = exp as? Column<*> + val value = row[exp] + val originalColumn = column?.let { alias.originalColumn(it) } + when { + originalColumn != null -> originalColumn to value + column?.table == alias.delegate -> null + else -> exp to value + } + }.toMap() + + return wrapRow(ResultRow.createAndFillValues(unwrapColumnValues(newFieldsMapping))) + } + + fun wrapRow(row: ResultRow, alias: QueryAlias): T { + require(alias.columns.any { (it.table as Alias<*>).delegate == table }) { "QueryAlias doesn't have any column from ${table.tableName} table" } + val originalColumns = alias.query.set.source.columns + val newFieldsMapping = row.fieldIndex.mapNotNull { (exp, _) -> + val value = row[exp] + when (exp) { + is Column if exp.table is Alias<*> -> { + val delegate = (exp.table as Alias<*>).delegate + val column = originalColumns.single { + delegate == it.table && exp.name == it.name + } + column to value + } + is Column if exp.table == table -> null + else -> exp to value + } + }.toMap() + + return wrapRow(ResultRow.createAndFillValues(unwrapColumnValues(newFieldsMapping))) + } + fun wrap(id: EntityID, row: ResultRow?): T { val transaction = TransactionManager.current() return transaction.entityCache.find(this, id) ?: createInstance(id, row).also { new -> diff --git a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/v1/javatime/DefaultsTest.kt b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/v1/javatime/DefaultsTest.kt index e474eb5308..55555f9ff8 100644 --- a/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/v1/javatime/DefaultsTest.kt +++ b/exposed-java-time/src/test/kotlin/org/jetbrains/exposed/v1/javatime/DefaultsTest.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.constraintNamePart import org.jetbrains.exposed.v1.tests.currentDialectTest @@ -25,7 +24,6 @@ import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.shared.expectException import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.time.* import java.time.temporal.ChronoUnit @@ -98,7 +96,6 @@ class DefaultsTest : DatabaseTestsBase() { assertEquals(defaultValue, returnedDefault, "Expected clientDefault to return $defaultValue, but was $returnedDefault") } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit01() { withTables(TableWithDBDefault) { @@ -119,7 +116,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit02() { withTables(TableWithDBDefault) { @@ -140,7 +136,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsInvokedOnlyOncePerEntity() { withTables(TableWithDBDefault) { @@ -154,7 +149,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsCanBeOverridden() { withTables(TableWithDBDefault) { @@ -599,7 +593,6 @@ class DefaultsTest : DatabaseTestsBase() { var timestamp: OffsetDateTime by DefaultTimestampTable.timestamp } - @Tag(MISSING_R2DBC_TEST) @Test fun testCustomDefaultTimestampFunctionWithEntity() { withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { @@ -639,7 +632,6 @@ class DefaultsTest : DatabaseTestsBase() { companion object : EntityClass(TableWithDefaultValue) } - @Tag(MISSING_R2DBC_TEST) @Test fun testExplicitInsertionOfDefaultValuesWithIdTable() { withTables(TableWithDefaultValue) { diff --git a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/v1/jodatime/JodaTimeDefaultsTest.kt b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/v1/jodatime/JodaTimeDefaultsTest.kt index a8f9bfbcd7..5731324db0 100644 --- a/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/v1/jodatime/JodaTimeDefaultsTest.kt +++ b/exposed-jodatime/src/test/kotlin/org/jetbrains/exposed/v1/jodatime/JodaTimeDefaultsTest.kt @@ -13,7 +13,6 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.constraintNamePart import org.jetbrains.exposed.v1.tests.currentDialectTest @@ -26,7 +25,6 @@ import org.jetbrains.exposed.v1.tests.shared.expectException import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.joda.time.LocalTime -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -61,7 +59,6 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { companion object : IntEntityClass(TableWithDBDefault) } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit01() { withTables(TableWithDBDefault) { @@ -82,7 +79,6 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit02() { // MySql 5 is excluded because it does not support `CURRENT_DATE()` as a default value @@ -104,7 +100,6 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsInvokedOnlyOncePerEntity() { withTables(TableWithDBDefault) { @@ -529,7 +524,6 @@ class JodaTimeDefaultsTest : DatabaseTestsBase() { var timestamp: DateTime by DefaultTimestampTable.timestamp } - @Tag(MISSING_R2DBC_TEST) @Test fun testCustomDefaultTimestampFunctionWithEntity() { withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { diff --git a/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonBColumnTests.kt b/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonBColumnTests.kt index 804799da38..53625b9ab9 100644 --- a/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonBColumnTests.kt +++ b/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonBColumnTests.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectTest import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections @@ -23,7 +22,6 @@ import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertFalse import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -133,7 +131,6 @@ class JsonBColumnTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDAOFunctionsWithJsonBColumn() { val dataTable = JsonTestsData.JsonBTable @@ -533,7 +530,6 @@ class JsonBColumnTests : DatabaseTestsBase() { } @Test - @Tag(MISSING_R2DBC_TEST) fun testFieldsOutsideTransaction() { lateinit var entity: MyEntity withTables(excludeSettings = binaryJsonNotSupportedDB, MyTable) { diff --git a/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonColumnTests.kt b/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonColumnTests.kt index 412da01941..1967b5c4b6 100644 --- a/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonColumnTests.kt +++ b/exposed-json/src/test/kotlin/org/jetbrains/exposed/v1/json/JsonColumnTests.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.v1.core.vendors.SQLServerDialect import org.jetbrains.exposed.v1.exceptions.UnsupportedByDialectException import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectTest import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections @@ -22,7 +21,6 @@ import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals import kotlin.test.assertNotNull @@ -112,7 +110,6 @@ class JsonColumnTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDAOFunctionsWithJsonColumn() { val dataTable = JsonTestsData.JsonTable diff --git a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/v1/datetime/DefaultsTest.kt b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/v1/datetime/DefaultsTest.kt index 03bf747c42..72e82cb98f 100644 --- a/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/v1/datetime/DefaultsTest.kt +++ b/exposed-kotlin-datetime/src/test/kotlin/org/jetbrains/exposed/v1/datetime/DefaultsTest.kt @@ -14,7 +14,6 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.constraintNamePart import org.jetbrains.exposed.v1.tests.currentDialectTest @@ -24,7 +23,6 @@ import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.time.OffsetDateTime import java.time.ZoneId @@ -96,7 +94,6 @@ class DefaultsTest : DatabaseTestsBase() { assertEquals(defaultValue, returnedDefault, "Expected clientDefault to return $defaultValue, but was $returnedDefault") } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit01() { withTables(TableWithDBDefault) { @@ -117,7 +114,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit02() { // MySql 5 is excluded because it does not support `CURRENT_DATE()` as a default value @@ -139,7 +135,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsInvokedOnlyOncePerEntity() { withTables(TableWithDBDefault) { @@ -649,7 +644,6 @@ class DefaultsTest : DatabaseTestsBase() { var timestamp: OffsetDateTime by DefaultTimestampTable.timestamp } - @Tag(MISSING_R2DBC_TEST) @Test fun testCustomDefaultTimestampFunctionWithEntity() { withTables(excludeSettings = TestDB.ALL - TestDB.ALL_POSTGRES - TestDB.MYSQL_V8 - TestDB.ALL_H2_V2, DefaultTimestampTable) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/AliasesTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/AliasesTests.kt index 169b45c489..224e1e21e6 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/AliasesTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/AliasesTests.kt @@ -8,11 +8,9 @@ import org.jetbrains.exposed.v1.dao.entityCache import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.dml.withCitiesAndUsers import org.jetbrains.exposed.v1.tests.shared.entities.EntityTestsData -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.math.BigDecimal import kotlin.test.assertEquals @@ -102,7 +100,6 @@ class AliasesTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testWrapRowWithAliasedTable() { withTables(EntityTestsData.XTable, EntityTestsData.YTable) { @@ -121,7 +118,6 @@ class AliasesTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testWrapRowWithAliasedQuery() { withTables(EntityTestsData.XTable, EntityTestsData.YTable) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/CoroutineTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/CoroutineTests.kt index 9187161565..8e956e5ef0 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/CoroutineTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/CoroutineTests.kt @@ -20,7 +20,6 @@ import org.jetbrains.exposed.v1.jdbc.transactions.experimental.withSuspendTransa import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC import org.jetbrains.exposed.v1.tests.TestDB import org.junit.jupiter.api.RepeatedTest @@ -327,7 +326,8 @@ class CoroutineTests : DatabaseTestsBase() { companion object : IntEntityClass(Testing) } - @Tag(MISSING_R2DBC_TEST) + // Skipped for r2dbc dao because it tests deprecated method that has no r2bdc alternative. + // If it's wrong, we could add it later @Test @CoroutinesTimeout(60000) fun testCoroutinesWithExceptionWithin() { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/DDLTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/DDLTests.kt index 86f78c5ec1..0244f11084 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/DDLTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/DDLTests.kt @@ -23,11 +23,9 @@ import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectTest import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.* import kotlin.test.assertEquals @@ -1028,7 +1026,6 @@ class DDLTests : DatabaseTestsBase() { } // https://github.com/JetBrains/Exposed/issues/112 - @Tag(MISSING_R2DBC_TEST) @Test fun testDropTableFlushesCache() { class Keyword(id: EntityID) : IntEntity(id) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/IdentifierManagerConcurrencyTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/IdentifierManagerConcurrencyTest.kt index 1777db0498..d0ea52c1c7 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/IdentifierManagerConcurrencyTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/IdentifierManagerConcurrencyTest.kt @@ -2,8 +2,6 @@ package org.jetbrains.exposed.v1.tests.shared import org.jetbrains.exposed.v1.core.statements.api.IdentifierManagerApi import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors @@ -25,7 +23,6 @@ import kotlin.test.assertTrue * threads (DataLoader batches, async statement preparation) that don't own the current * transaction thread-local. */ -@Tag(MISSING_R2DBC_TEST) class IdentifierManagerConcurrencyTest : DatabaseTestsBase() { @Test diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/ddl/SequencesTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/ddl/SequencesTests.kt index 1cb0ad31b2..8b3b6889b9 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/ddl/SequencesTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/ddl/SequencesTests.kt @@ -15,7 +15,6 @@ import org.jetbrains.exposed.v1.dao.UuidEntityClass import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectMetadataTest import org.jetbrains.exposed.v1.tests.currentDialectTest @@ -23,7 +22,6 @@ import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertFalse import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -386,7 +384,6 @@ class SequencesTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testAutoIncrementColumnAccessWithEntity() { Assumptions.assumeTrue(TestDB.POSTGRESQL in TestDB.enabledDialects()) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ColumnWithTransformTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ColumnWithTransformTest.kt index 2fedea9d13..2325c825e3 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ColumnWithTransformTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ColumnWithTransformTest.kt @@ -16,10 +16,8 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.dao.entityCache import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -203,7 +201,6 @@ class ColumnWithTransformTest : DatabaseTestsBase() { companion object : IntEntityClass(TransformTable) } - @Tag(MISSING_R2DBC_TEST) @Test fun testTransformedValuesWithDAO() { withTables(TransformTable) { @@ -221,7 +218,6 @@ class ColumnWithTransformTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testEntityWithDefaultValue() { withTables(TransformTable) { @@ -388,7 +384,6 @@ class ColumnWithTransformTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testWrapRowWithAliases() { withTables(TransformTable) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/InsertTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/InsertTests.kt index e6014e933a..c403fca0e6 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/InsertTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/InsertTests.kt @@ -19,7 +19,6 @@ import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.statements.toExecutable import org.jetbrains.exposed.v1.jdbc.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.NOT_APPLICABLE_TO_R2DBC import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentTestDB @@ -387,7 +386,6 @@ class InsertTests : DatabaseTestsBase() { } // https://github.com/JetBrains/Exposed/issues/192 - @Tag(MISSING_R2DBC_TEST) @Test fun testInsertWithColumnNamedWithKeyword() { withTables(OrderedDataTable) { @@ -575,7 +573,6 @@ class InsertTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testOptReferenceAllowsNullValues() { withTables(EntityTests.Posts) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ReturningTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ReturningTests.kt index af2992c2a5..acc1cbdc34 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ReturningTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/ReturningTests.kt @@ -14,10 +14,8 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.statements.ReturningBlockingExecutable import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -135,7 +133,6 @@ class ReturningTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testUpsertReturningWithDAO() { withTables(TestDB.ALL - returningSupportedDb, Items) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/SelectTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/SelectTests.kt index 0b84e1f09f..912b127073 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/SelectTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/dml/SelectTests.kt @@ -5,14 +5,12 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.entities.EntityTests import org.jetbrains.exposed.v1.tests.shared.expectException import org.junit.jupiter.api.Assumptions -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertNull @@ -218,7 +216,6 @@ class SelectTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testInListWithEntityIDColumns() { withTables(EntityTests.Posts, EntityTests.Boards, EntityTests.Categories) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/ArrayColumnTypeTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/ArrayColumnTypeTests.kt index bcc5082b19..599337f601 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/ArrayColumnTypeTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/ArrayColumnTypeTests.kt @@ -12,14 +12,12 @@ import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.exceptions.ExposedSQLException import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentDialectTest import org.jetbrains.exposed.v1.tests.shared.assertEqualLists import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertTrue import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertContentEquals import kotlin.test.assertNotNull @@ -275,7 +273,6 @@ class ArrayColumnTypeTests : DatabaseTestsBase() { var doubles by ArrayTestTable.doubles } - @Tag(MISSING_R2DBC_TEST) @Test fun testArrayColumnWithDAOFunctions() { withTestTableAndExcludeSettings { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/VectorColumnTypeTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/VectorColumnTypeTests.kt index 69faa38c74..409c62b0e5 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/VectorColumnTypeTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/types/VectorColumnTypeTests.kt @@ -12,13 +12,11 @@ import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.assertFailAndRollback import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertNull import kotlin.math.abs @@ -374,7 +372,6 @@ class VectorColumnTypeTests : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testVectorTypeWithDAO() { withDb(vectorTypeSupportedDb) { testDb -> From 15f63ceaf3db12af1a5a40097880b8acdc8bb846 Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Mon, 1 Jun 2026 14:09:23 +0200 Subject: [PATCH 6/7] feat: port JDBC DAO entity tests to R2DBC (part 3) --- exposed-dao-r2dbc-tests/build.gradle.kts | 2 + .../exposed/dao/r2dbc/tests/TestBaase.kt | 6 + .../tests/money/R2dbcMoneyDefaultsTest.kt | 100 ++ .../dao/r2dbc/tests/money/R2dbcMoneyTests.kt | 65 ++ .../shared/R2dbcCompositeIdTableEntityTest.kt | 860 ++++++++++++++++++ .../shared/R2dbcEntityBugsRegressionTest.kt | 175 ++++ .../exposed/r2dbc/dao/R2dbcEntity.kt | 24 + .../exposed/r2dbc/dao/R2dbcEntityClass.kt | 118 ++- .../dao/relationships/R2dbcBackReference.kt | 12 +- .../dao/relationships/R2dbcEagerLoading.kt | 119 +++ .../r2dbc/dao/relationships/R2dbcReferrers.kt | 64 +- .../R2dbcRelationshipExtensions.kt | 47 +- .../dao/relationships/SuspendAccessor.kt | 159 +++- .../exposed/v1/money/MoneyDefaultsTest.kt | 3 - .../jetbrains/exposed/v1/money/MoneyTests.kt | 3 - .../entities/CompositeIdTableEntityTest.kt | 4 +- .../entities/EntityBugsRegressionTest.kt | 3 - 17 files changed, 1669 insertions(+), 95 deletions(-) create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/TestBaase.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyDefaultsTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyTests.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcCompositeIdTableEntityTest.kt create mode 100644 exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt diff --git a/exposed-dao-r2dbc-tests/build.gradle.kts b/exposed-dao-r2dbc-tests/build.gradle.kts index 489627bda8..d464ec3828 100644 --- a/exposed-dao-r2dbc-tests/build.gradle.kts +++ b/exposed-dao-r2dbc-tests/build.gradle.kts @@ -43,11 +43,13 @@ dependencies { testImplementation(project(":exposed-jodatime")) testImplementation(project(":exposed-json")) testImplementation(project(":exposed-crypt")) + testImplementation(project(":exposed-money")) implementation(libs.slf4j) implementation(libs.log4j.slf4j.impl) implementation(libs.log4j.api) implementation(libs.log4j.core) + testImplementation(libs.moneta) testRuntimeOnly(libs.r2dbc.pool) testImplementation(libs.r2dbc.h2) { diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/TestBaase.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/TestBaase.kt new file mode 100644 index 0000000000..e5086e45a5 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/TestBaase.kt @@ -0,0 +1,6 @@ +package org.jetbrains.exposed.dao.r2dbc.tests + +import org.jetbrains.exposed.v1.core.transactions.nullableTransactionScope +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB + +internal var currentTestDB by nullableTransactionScope() diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyDefaultsTest.kt new file mode 100644 index 0000000000..640a2a0bb3 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyDefaultsTest.kt @@ -0,0 +1,100 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.money + +import kotlinx.coroutines.flow.toList +import org.javamoney.moneta.Money +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.money.compositeMoney +import org.jetbrains.exposed.v1.money.nullable +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.assertNull +import java.math.BigDecimal +import kotlin.test.Test + +class R2dbcMoneyDefaultsTest : R2dbcDatabaseTestsBase() { + object TableWithDBDefault : IntIdTable() { + val defaultValue: Money = Money.of(BigDecimal.ONE, "USD") + + var cIndex = 0 + val field = varchar("field", 100) + val t1 = compositeMoney(10, 0, "t1").default(defaultValue) + val t2 = compositeMoney(10, 0, "t2").nullable() + val clientDefault = integer("clientDefault").clientDefault { cIndex++ } + } + + class DBDefault(id: EntityID) : IntR2dbcEntity(id) { + var field by TableWithDBDefault.field + var t1 by TableWithDBDefault.t1 + var t2 by TableWithDBDefault.t2 + val clientDefault by TableWithDBDefault.clientDefault + + override fun hashCode(): Int = id.value.hashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DBDefault) return false + if (other.t1 != other.t1) return false + if (other.t2 != other.t2) return false + if (other.clientDefault != other.clientDefault) return false + + return true + } + + companion object : IntR2dbcEntityClass(TableWithDBDefault) + } + + + @Test + fun testDefaultsWithExplicit() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = Money.of(BigDecimal.TEN, "USD") + } + ) + flushCache() + created.forEach { + DBDefault.removeFromCache(it) + } + + val entities = DBDefault.all().toList() + assertEqualCollections(created.map { it.id }, entities.map { it.id }) + } + } + + @Test + fun testDefaultsInvokedOnlyOncePerEntity() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + val db2 = DBDefault.new { field = "2" } + flushCache() + assertEquals(0, db1.clientDefault) + assertEquals(1, db2.clientDefault) + assertEquals(2, TableWithDBDefault.cIndex) + assertEquals(TableWithDBDefault.defaultValue, db1.t1) + } + } + + @Test + fun testNullableCompositeColumnType() { + withTables(TableWithDBDefault) { + TableWithDBDefault.cIndex = 0 + val db1 = DBDefault.new { field = "1" } + flushCache() + assertNull(db1.t2) + val money = Money.of(BigDecimal.ONE, "USD") + db1.t2 = money + db1.refresh(flush = true) + assertEquals(money, db1.t1) + assertEquals(TableWithDBDefault.defaultValue, db1.t1) + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyTests.kt new file mode 100644 index 0000000000..d2bc3b1bfd --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/money/R2dbcMoneyTests.kt @@ -0,0 +1,65 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.money + +import kotlinx.coroutines.flow.toList +import org.javamoney.moneta.Money +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.money.compositeMoney +import org.jetbrains.exposed.v1.money.nullable +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import javax.money.CurrencyUnit +import javax.money.MonetaryAmount + +private const val AMOUNT_SCALE = 5 + +class R2dbcMoneyTests : R2dbcDatabaseTestsBase() { + @Test + fun testSearchByCompositeColumn() { + val money = Money.of(BigDecimal.TEN, "USD") + + withTables(Account) { + Account.insertAndGetId { + it[composite_money] = money + } + + val predicates = listOf( + Account.composite_money eq money, + (Account.composite_money.currency eq money.currency), + (Account.composite_money.amount eq BigDecimal.TEN) + ) + + predicates.forEach { + val found = AccountDao.find { it }.toList() + + assertEquals(1, found.count()) + val next = found.iterator().next() + assertEquals(money, next.money) + assertEquals(money.currency, next.currency) + assertEquals(BigDecimal.TEN.setScale(AMOUNT_SCALE), next.amount) + } + } + } +} + +class AccountDao(id: EntityID) : IntR2dbcEntity(id) { + + val money: MonetaryAmount? by Account.composite_money + + val currency: CurrencyUnit? by Account.composite_money.currency + + val amount: BigDecimal? by Account.composite_money.amount + + companion object : R2dbcEntityClass(Account) +} + +object Account : IntIdTable("AccountTable") { + + val composite_money = compositeMoney(8, AMOUNT_SCALE, "composite_money").nullable() +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcCompositeIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcCompositeIdTableEntityTest.kt new file mode 100644 index 0000000000..d9709ef5cc --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcCompositeIdTableEntityTest.kt @@ -0,0 +1,860 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.dao.r2dbc.tests.currentTestDB +import org.jetbrains.exposed.r2dbc.dao.CompositeR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.CompositeR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.entityCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.with +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.idParam +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.isNotNull +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.neq +import org.jetbrains.exposed.v1.core.notInList +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.deleteWhere +import org.jetbrains.exposed.v1.r2dbc.exists +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.insertAndGetId +import org.jetbrains.exposed.v1.r2dbc.select +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualCollections +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEqualLists +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertTrue +import org.jetbrains.exposed.v1.r2dbc.tests.shared.expectException +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import kotlin.test.assertIs +import kotlin.uuid.Uuid + +class R2dbcCompositeIdTableEntityTest : R2dbcDatabaseTestsBase() { + object Publishers : CompositeIdTable("publishers") { + val pubId = integer("pub_id").autoIncrement().entityId() + val isbn = uuid("isbn_code").autoGenerate().entityId() + val name = varchar("publisher_name", 32) + + override val primaryKey = PrimaryKey(pubId, isbn) + } + + class Publisher(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(Publishers) + + var name by Publishers.name + val authors by Author referrersOnSuspend Authors + val office by Office optionalBackReferencedOnSuspend Offices + val allOffices by Office optionalReferrersOnSuspend Offices + } + + // IntIdTable with 1 key columns - int (db-generated) + object Authors : IntIdTable("authors") { + val publisherId = integer("publisher_id") + val publisherIsbn = uuid("publisher_isbn") + val penName = varchar("pen_name", 32) + + // FK constraint with multiple columns is created as a table-level constraint + init { + foreignKey(publisherId, publisherIsbn, target = Publishers.primaryKey) + } + } + + class Author(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Authors) + + val publisher by Publisher referencedOnSuspend Authors + var penName by Authors.penName + } + + // CompositeIdTable with 1 key column - int (db-generated) + object Books : CompositeIdTable("books") { + val bookId = integer("book_id").autoIncrement().entityId() + val title = varchar("title", 32) + val author = optReference("author_id", Authors) + + override val primaryKey = PrimaryKey(bookId) + } + + class Book(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(Books) + + var title by Books.title + val author by Author optionalReferencedOnSuspend Books.author + val review by Review backReferencedOnSuspend Reviews + } + + // CompositeIdTable with 2 key columns - string & long (neither db-generated) + object Reviews : CompositeIdTable("reviews") { + val content = varchar("code", 8).entityId() + val rank = long("rank").entityId() + val book = integer("book_id") + + override val primaryKey = PrimaryKey(content, rank) + + init { + foreignKey(book, target = Books.primaryKey) + } + } + + class Review(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(Reviews) + + // R2DBC: relationship is mutated via `book set bookValue` (infix) on the accessor; + // see memory note about the "Suspend" accessor pattern. + val book by Book referencedOnSuspend Reviews + } + + // CompositeIdTable with 3 key columns - string, string, & int (none db-generated) + object Offices : CompositeIdTable("offices") { + val zipCode = varchar("zip_code", 8).entityId() + val name = varchar("name", 64).entityId() + val areaCode = integer("area_code").entityId() + val staff = long("staff").nullable() + val publisherId = integer("publisher_id").nullable() + val publisherIsbn = uuid("publisher_isbn").nullable() + + override val primaryKey = PrimaryKey(zipCode, name, areaCode) + + init { + foreignKey(publisherId, publisherIsbn, target = Publishers.primaryKey) + } + } + + class Office(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(Offices) + + var staff by Offices.staff + val publisher by Publisher optionalReferencedOnSuspend Offices + } + + private val allTables = arrayOf(Publishers, Authors, Books, Reviews, Offices) + + @Test + fun testCreateAndDropCompositeIdTable() { + withDb { + try { + SchemaUtils.create(tables = allTables) + + allTables.forEach { assertTrue(it.exists()) } + assertTrue(SchemaUtils.statementsRequiredToActualizeScheme(tables = allTables).isEmpty()) + } finally { + SchemaUtils.drop(tables = allTables) + } + } + } + + @Test + fun testCreateWithMissingIdColumns() { + val missingIdsTable = object : CompositeIdTable("missing_ids_table") { + val age = integer("age") + val name = varchar("name", 50) + override val primaryKey = PrimaryKey(age, name) + } + + withDb { + // table can be created with no issue + SchemaUtils.create(missingIdsTable) + + expectException { + // but trying to use id property requires idColumns not being empty + runBlocking { + missingIdsTable.select(missingIdsTable.id).toList() + } + } + + SchemaUtils.drop(missingIdsTable) + } + } + + @Test + fun testInsertAndSelectUsingDAO() { + withTables(Publishers) { + val p1 = Publisher.new { + name = "Publisher A" + } + + val result1 = Publisher.all().single() + assertEquals("Publisher A", result1.name) + // can compare entire entity objects + assertEquals(p1, result1) + // or entire entity ids + assertEquals(p1.id, result1.id) + // or the value wrapped by entity id + assertEquals(p1.id.value, result1.id.value) + // or the composite id components + assertEquals(p1.id.value[Publishers.pubId], result1.id.value[Publishers.pubId]) + assertEquals(p1.id.value[Publishers.isbn], result1.id.value[Publishers.isbn]) + + Publisher.new { name = "Publisher B" } + Publisher.new { name = "Publisher C" } + + val resul2 = Publisher.all().toList() + assertEquals(3, resul2.size) + } + } + + + @Test + fun testInsertAndSelectUsingDSL() { + withTables(Publishers) { + Publishers.insert { + it[name] = "Publisher A" + } + + val result = Publishers.selectAll().single() + assertEquals("Publisher A", result[Publishers.name]) + + // test all id column components are accessible from single ResultRow access + val idResult = result[Publishers.id] + assertIs>(idResult) + val pubIdResult = idResult.value[Publishers.pubId] + assertEquals(result[Publishers.pubId], pubIdResult) + assertEquals(result[Publishers.isbn], idResult.value[Publishers.isbn]) + + // test that using composite id column in DSL query builder works + val dslQuery = Publishers + .select(Publishers.id) // should deconstruct to 2 columns + .where { Publishers.id eq idResult } // should deconstruct to 2 ops + .prepareSQL(this) + val selectClause = dslQuery.substringAfter("SELECT ").substringBefore(" FROM") + // id column should deconstruct to 2 columns from PK + assertEquals(2, selectClause.split(", ", ignoreCase = true).size) + val whereClause = dslQuery.substringAfter("WHERE ") + // 2 column in composite PK to check, joined by single AND operator + assertEquals(2, whereClause.split("AND", ignoreCase = true).size) + + // test equality comparison fails if composite columns do not match + expectException { + val fake = EntityID(CompositeID { it[Publishers.pubId] = 7 }, Publishers) + Publishers.selectAll().where { Publishers.id eq fake } + } + + // test equality comparison succeeds with partial match to composite column unwrapped value + val pubIdValue: Int = pubIdResult.value + assertEquals(0, Publishers.selectAll().where { Publishers.pubId neq pubIdValue }.count()) + } + } + + @Test + fun testInsertWithCompositeIdAutoGeneratedPartsUsingDAO() { + // it seems that SQLServer does not support partial generation of ID + withTables(excludeSettings = listOf(TestDB.SQLSERVER), Publishers) { + // test missing autoGenerated Uuid + val p1 = Publisher.new( + CompositeID { + it[Publishers.pubId] = 578 + } + ) { + name = "Publisher A" + } + val found1 = Publisher.find { Publishers.pubId eq 578 }.single() + assertEquals(p1.id, found1.id) + assertEquals("Publisher A", found1.name) + + // test missing autoIncrement ID + val isbn = Uuid.random() + val p2 = Publisher.new( + CompositeID { + it[Publishers.isbn] = isbn + } + ) { + name = "Publisher B" + } + val found2 = Publisher.find { Publishers.isbn eq isbn }.single() + assertEquals(p2.id, found2.id) + val expectedNextVal1 = if (currentTestDB in TestDB.ALL_MYSQL_LIKE) 579 else 1 + assertEquals(expectedNextVal1, found2.id.value[Publishers.pubId].value) + } + } + + + @Test + fun testInsertWithCompositeIdAutoGeneratedPartsAndMissingNotGeneratedPartUsingDAO() { + withTables(tables = allTables) { + val publisherA = Publisher.new { + name = "Publisher A" + } + // R2DBC: composite-FK `set` reads the parent's id columns from its writeValues/_readValues + // (it can't do JDBC's sync `value.id.value` lazy-flush because `set` is non-suspend). + // Flush the parent before linking it from the child. + flushCache() + val authorA = Author.new { + publisher set publisherA + penName = "Author A" + } + flushCache() // it's really annoying + val bookA = Book.new { + title = "Book A" + author set authorA + } + flushCache() + val compositeID = CompositeID { + it[Reviews.rank] = 10L + } + expectException { + Review.new(compositeID) { + book set bookA + } + } + } + } + + @Test + fun testInsertAndGetCompositeIds() { + withTables(excludeSettings = listOf(TestDB.SQLSERVER), Publishers) { + // insert individual components + val id1: EntityID = Publishers.insertAndGetId { + it[pubId] = 725 + it[isbn] = Uuid.random() + it[name] = "Publisher A" + } + assertEquals(725, id1.value[Publishers.pubId].value) + + val id2: EntityID = Publishers.insertAndGetId { + it[name] = "Publisher B" + } + val expectedNextVal1 = if (currentTestDB in TestDB.ALL_MYSQL_LIKE) 726 else 1 + assertEquals(expectedNextVal1, id2.value[Publishers.pubId].value) + + // insert as composite ID + val id3: EntityID = Publishers.insertAndGetId { + it[id] = CompositeID { id -> + id[pubId] = 999 + id[isbn] = Uuid.random() + } + it[name] = "Publisher C" + } + assertEquals(999, id3.value[Publishers.pubId].value) + + // insert as EntityID + val id4: EntityID = Publishers.insertAndGetId { + it[id] = EntityID( + CompositeID { id -> + id[pubId] = 111 + id[isbn] = Uuid.random() + }, + Publishers + ) + it[name] = "Publisher C" + } + assertEquals(111, id4.value[Publishers.pubId].value) + + // insert as partially filled composite ID with generated Uuid part + val id5: EntityID = Publishers.insertAndGetId { + it[id] = CompositeID { id -> + id[pubId] = 1001 + } + it[name] = "Publisher C" + } + assertEquals(1001, id5.value[Publishers.pubId].value) + + // insert as partially filled composite ID with autoincrement part + val id6: EntityID = Publishers.insertAndGetId { + it[id] = CompositeID { id -> + id[isbn] = Uuid.random() + } + it[name] = "Publisher C" + } + val expectedNextVal2 = if (currentTestDB in TestDB.ALL_MYSQL_LIKE) 1002 else 2 + assertEquals(expectedNextVal2, id6.value[Publishers.pubId].value) + } + } + + @Test + fun testInsertUsingManualCompositeIds() { + withTables(excludeSettings = listOf(TestDB.SQLSERVER), Publishers) { + // manual using DSL + Publishers.insert { + it[pubId] = 725 + it[isbn] = Uuid.random() + it[name] = "Publisher A" + } + + assertEquals(725, Publishers.selectAll().single()[Publishers.pubId].value) + + // manual using DAO - all PK columns + val fullId = CompositeID { + it[Publishers.pubId] = 611 + it[Publishers.isbn] = Uuid.random() + } + val p2Id = Publisher.new(fullId) { + name = "Publisher B" + }.id + // R2DBC: id.value is non-suspend, so the composite id must be populated explicitly + // via flushCache (JDBC's `id.value` getter implicitly triggers `invokeOnNoValue`). + flushCache() + assertEquals(611, p2Id.value[Publishers.pubId].value) + assertEquals(611, Publisher.findById(p2Id)?.id?.value?.get(Publishers.pubId)?.value) + } + } + + @Test + fun testFindByCompositeId() { + withTables(excludeSettings = listOf(TestDB.SQLSERVER), Publishers) { + val id1: EntityID = Publishers.insertAndGetId { + it[pubId] = 725 + it[isbn] = Uuid.random() + it[name] = "Publisher A" + } + + val p1 = Publisher.findById(id1) + assertNotNull(p1) + assertEquals(725, p1.id.value[Publishers.pubId].value) + + val id2: EntityID = Publisher.new { + name = "Publisher B" + }.id + // R2DBC: `id2.value` is non-suspend and can't lazy-flush like JDBC's + // `DaoEntityID.invokeOnNoValue`. Flush explicitly so the generated composite id + // is populated before the `id.value[...]` access on the next lines. + flushCache() + + val p2 = Publisher.findById(id2) + assertNotNull(p2) + assertEquals("Publisher B", p2.name) + assertEquals(id2.value[Publishers.pubId], p2.id.value[Publishers.pubId]) + + // test findById() using CompositeID value + val compositeId1: CompositeID = id1.value + val p3 = Publisher.findById(compositeId1) + assertNotNull(p3) + assertEquals(p1, p3) + } + } + + + @Test + fun testFindWithDSLBuilder() { + withTables( Publishers) { + val p1 = Publisher.new { + name = "Publisher A" + } + + assertEquals(p1.id, Publisher.find { Publishers.name like "% A" }.single().id) + + val p2 = Publisher.find { Publishers.id eq p1.id }.single() + assertEquals(p1, p2) + + // test select using partial match to composite column unwrapped value + val existingIsbnValue: Uuid = p1.id.value[Publishers.isbn].value + val p3 = Publisher.find { Publishers.isbn eq existingIsbnValue }.single() + assertEquals(p1, p3) + } + } + + @Test + fun testUpdateCompositeEntity() { + withTables( Publishers) { + val p1 = Publisher.new { + name = "Publisher A" + } + + p1.name = "Publisher B" + + assertEquals("Publisher B", Publisher.all().single().name) + } + } + + @Test + fun testDeleteCompositeEntity() { + withTables( Publishers) { + val p1 = Publisher.new { + name = "Publisher A" + } + val p2 = Publisher.new { + name = "Publisher B" + } + + assertEquals(2, Publisher.all().count()) + + p1.delete() + + val result = Publisher.all().single() + assertEquals("Publisher B", result.name) + assertEquals(p2.id, result.id) + + // test delete using partial match to composite column unwrapped value + val existingPubIdValue: Int = p2.id.value[Publishers.pubId].value + Publishers.deleteWhere { pubId eq existingPubIdValue } + assertEquals(0, Publisher.all().count()) + } + } + + object Towns : CompositeIdTable("towns") { + val zipCode = varchar("zip_code", 8).entityId() + val name = varchar("name", 64).entityId() + val population = long("population").nullable() + override val primaryKey = PrimaryKey(zipCode, name) + } + + class Town(id: EntityID) : CompositeR2dbcEntity(id) { + companion object : CompositeR2dbcEntityClass(Towns) + + var population by Towns.population + } + + @Test + fun testIsNullAndEqWithAlias() { + withTables(Towns) { + val townAValue = CompositeID { + it[Towns.zipCode] = "1A2 3B4" + it[Towns.name] = "Town A" + } + val townAId = Towns.insertAndGetId { it[id] = townAValue } + + val smallCity = Towns.alias("small_city") + + val result1 = smallCity.selectAll().where { + smallCity[Towns.id].isNotNull() and (smallCity[Towns.id] eq townAId) + }.single() + assertNull(result1[smallCity[Towns.population]]) + + val result2 = smallCity.select(smallCity[Towns.name]).where { + smallCity[Towns.id] eq townAId.value + }.single() + assertEquals(townAValue[Towns.name], result2[smallCity[Towns.name]]) + } + } + + @Test + fun testIdParamWithCompositeValue() { + withTables(Towns) { + val townAValue = CompositeID { + it[Towns.zipCode] = "1A2 3B4" + it[Towns.name] = "Town A" + } + val townAId = Towns.insertAndGetId { + it[id] = townAValue + it[population] = 4 + } + + val query = Towns.selectAll().where { Towns.id eq idParam(townAId, Towns.id) } + val whereClause = query.prepareSQL(this, prepared = true).substringAfter("WHERE ") + assertEquals("(${fullIdentity(Towns.zipCode)} = ?) AND (${fullIdentity(Towns.name)} = ?)", whereClause) + assertEquals(4, query.single()[Towns.population]) + } + } + + @Test + fun testFlushingUpdatedEntity() { + withTables(Towns) { + val id = CompositeID { + it[Towns.zipCode] = "1A2 3B4" + it[Towns.name] = "Town A" + } + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + Town.new(id) { + population = 1000 + } + } + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + val town = Town[id] + town.population = 2000 + } + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + val town = Town[id] + assertEquals(2000, town.population) + } + } + } + + @Test + fun testInsertAndSelectReferencedEntities() { + withTables( tables = allTables) { + val publisherA = Publisher.new { + name = "Publisher A" + } + // R2DBC: composite-FK `set` reads the parent's PK columns from writeValues/_readValues; + // it can't sync-flush like JDBC. Flush each parent before linking it from a child. + flushCache() + val authorA = Author.new { + publisher set publisherA + penName = "Author A" + } + val authorB = Author.new { + publisher set publisherA + penName = "Author B" + } + flushCache() + val bookA = Book.new { + title = "Book A" + author set authorB + } + Book.new { + title = "Book B" + author set authorB + } + flushCache() + val reviewIdValue = CompositeID { + it[Reviews.content] = "Not bad" + it[Reviews.rank] = 12345 + } + val reviewA: Review = Review.new(reviewIdValue) { + book set bookA + } + val officeAIdValue = CompositeID { + it[Offices.zipCode] = "1A2 3B4" + it[Offices.name] = "Office A" + it[Offices.areaCode] = 789 + } + val officeA = Office.new(officeAIdValue) {} + val officeBIdValue = CompositeID { + it[Offices.zipCode] = "5C6 7D8" + it[Offices.name] = "Office B" + it[Offices.areaCode] = 456 + } + val officeB = Office.new(officeBIdValue) { + publisher set publisherA + } + flushCache() + + // child entity references — R2DBC accessors are suspend lambdas, so each `.publisher` + // / `.author` / `.book` etc. needs `()` to actually fetch the related entity. + assertEquals(publisherA.id.value[Publishers.pubId], authorA.publisher().id.value[Publishers.pubId]) + assertEquals(publisherA, authorA.publisher()) + assertEquals(publisherA, authorB.publisher()) + assertEquals(publisherA, bookA.author()?.publisher()) + assertEquals(authorB, bookA.author()) + assertEquals(bookA.id, reviewA.book().id) + assertEquals(authorB, reviewA.book().author()) + assertNull(officeA.publisher()) + assertEquals(publisherA, officeB.publisher()) + + // parent entity references + assertEquals(reviewA, bookA.review()) + assertEqualCollections(publisherA.authors().toList(), listOf(authorA, authorB)) + assertNotNull(publisherA.office()) + // if multiple children reference parent, backReferencedOn & optBackReferencedOn save last one + assertEquals(officeB, publisherA.office()) + assertEqualCollections(publisherA.allOffices().toList(), listOf(officeB)) + } + } + + @Test + fun testInListWithCompositeIdEntities() { + withTables( Publishers) { + val id1: EntityID = Publishers.insertAndGetId { + it[name] = "Publisher A" + } + val id2: EntityID = Publishers.insertAndGetId { + it[name] = "Publisher B" + } + + val compositeIds = listOf(id1.value, id2.value) + val keyColumns = Publishers.idColumns.toList() + val result1 = Publishers.selectAll().where { keyColumns inList compositeIds }.count() + assertEquals(2, result1) + val result2 = Publishers.selectAll().where { keyColumns notInList compositeIds }.count() + assertEquals(0, result2) + + val result3 = Publishers.selectAll().where { Publishers.id inList compositeIds }.count() + assertEquals(2, result3) + val result4 = Publishers.selectAll().where { Publishers.id notInList compositeIds }.count() + assertEquals(0, result4) + } + } + + @Test + fun testPreloadReferencedOn() { + withTables( tables = allTables) { + val publisherA = Publisher.new { + name = "Publisher A" + } + // R2DBC: composite-FK `set` needs the parent flushed first; see testInsertAndSelectReferencedEntities. + flushCache() + val authorA = Author.new { + publisher set publisherA + penName = "Author A" + } + Author.new { + publisher set publisherA + penName = "Author B" + } + val officeAIdValue = CompositeID { + it[Offices.zipCode] = "1A2 3B4" + it[Offices.name] = "Office A" + it[Offices.areaCode] = 789 + } + val officeA = Office.new(officeAIdValue) {} + val officeBIdValue = CompositeID { + it[Offices.zipCode] = "5C6 7D8" + it[Offices.name] = "Office B" + it[Offices.areaCode] = 456 + } + val officeB = Office.new(officeBIdValue) { + publisher set publisherA + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload referencedOn - child to single parent + Author.find { Authors.id eq authorA.id }.first().load(Author::publisher) + val foundAuthor = Author.testCache(authorA.id) + assertNotNull(foundAuthor) + assertEquals(publisherA.id, Publisher.testCache(foundAuthor.readCompositeIDValues(Publishers))?.id) + } + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload optionalReferencedOn - child to single parent? + Office.all().with(Office::publisher) + val foundOfficeA = Office.testCache(officeA.id) + assertNotNull(foundOfficeA) + val foundOfficeB = Office.testCache(officeB.id) + assertNotNull(foundOfficeB) + assertNull(foundOfficeA.readValues[Offices.publisherId]) + assertNull(foundOfficeA.readValues[Offices.publisherIsbn]) + assertEquals(publisherA.id, Publisher.testCache(foundOfficeB.readCompositeIDValues(Publishers))?.id) + } + } + } + + @Test + fun testPreloadBackReferencedOn() { + withTables( tables = allTables) { + val publisherA = Publisher.new { + name = "Publisher A" + } + // R2DBC: composite-FK `set` needs the parent flushed first. + flushCache() + val officeAIdValue = CompositeID { + it[Offices.zipCode] = "1A2 3B4" + it[Offices.name] = "Office A" + it[Offices.areaCode] = 789 + } + Office.new(officeAIdValue) {} + val officeBIdValue = CompositeID { + it[Offices.zipCode] = "5C6 7D8" + it[Offices.name] = "Office B" + it[Offices.areaCode] = 456 + } + val officeB = Office.new(officeBIdValue) { + publisher set publisherA + } + val bookA = Book.new { + title = "Book A" + } + flushCache() + val reviewIdValue = CompositeID { + it[Reviews.content] = "Not bad" + it[Reviews.rank] = 12345 + } + val reviewA: Review = Review.new(reviewIdValue) { + book set bookA + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload backReferencedOn - parent to single child + val cache = TransactionManager.current().entityCache + Book.find { Books.id eq bookA.id }.first().load(Book::review) + val result = cache.getReferrers(bookA.id, Reviews.book)?.map { it.id }?.toList().orEmpty() + assertEqualLists(listOf(reviewA.id), result) + } + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload optionalBackReferencedOn - parent to single child? + val cache = TransactionManager.current().entityCache + Publisher.find { Publishers.id eq publisherA.id }.first().load(Publisher::office) + val result = cache.getReferrers(publisherA.id, Offices.publisherId)?.map { it.id }?.toList().orEmpty() + assertEqualLists(listOf(officeB.id), result) + } + } + } + + @Test + fun testPreloadReferrersOn() { + withTables( tables = allTables) { + val publisherA = Publisher.new { + name = "Publisher A" + } + // R2DBC: composite-FK `set` needs the parent flushed first. + flushCache() + val authorA = Author.new { + publisher set publisherA + penName = "Author A" + } + val authorB = Author.new { + publisher set publisherA + penName = "Author B" + } + val officeAIdValue = CompositeID { + it[Offices.zipCode] = "1A2 3B4" + it[Offices.name] = "Office A" + it[Offices.areaCode] = 789 + } + Office.new(officeAIdValue) {} + val officeBIdValue = CompositeID { + it[Offices.zipCode] = "5C6 7D8" + it[Offices.name] = "Office B" + it[Offices.areaCode] = 456 + } + val officeB = Office.new(officeBIdValue) { + publisher set publisherA + } + + commit() + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload referrersOn - parent to multiple children + val cache = TransactionManager.current().entityCache + Publisher.find { Publishers.id eq publisherA.id }.first().load(Publisher::authors) + val result = cache.getReferrers(publisherA.id, Authors.publisherId)?.map { it.id }?.toList().orEmpty() + assertEqualLists(listOf(authorA.id, authorB.id), result) + } + + inTopLevelSuspendTransaction(transactionIsolation = IsolationLevel.SERIALIZABLE) { + maxAttempts = 1 + // preload optionalReferrersOn - parent to multiple children? + val cache = TransactionManager.current().entityCache + Publisher.all().with(Publisher::allOffices) + val result = cache.getReferrers(publisherA.id, Offices.publisherId)?.map { it.id }?.toList().orEmpty() + assertEqualLists(listOf(officeB.id), result) + } + } + } + + private fun R2dbcEntity<*>.readCompositeIDValues(table: CompositeIdTable): EntityID { + val referenceColumns = this.klass.table.foreignKeys.single().references + return EntityID( + CompositeID { + referenceColumns.forEach { (child, parent) -> + it[parent as Column>] = this.readValues[child] as Any + } + }, + table + ) + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt new file mode 100644 index 0000000000..4fd9d2c616 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt @@ -0,0 +1,175 @@ +package org.jetbrains.exposed.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.LongR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import kotlinx.coroutines.flow.first +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.dao.id.LongIdTable +import org.jetbrains.exposed.v1.r2dbc.insert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.TestDB +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import kotlin.test.Test + +class `Table id not in Record Test issue 1341` : R2dbcDatabaseTestsBase() { + object NamesTable : IdTable("names_table") { + val first = varchar("first", 50) + + val second = varchar("second", 50) + + override val id = integer("id").autoIncrement().entityId() + + override val primaryKey = PrimaryKey(id) + } + + object AccountsTable : IdTable("accounts_table") { + val name = reference("name", NamesTable) + override val id: Column> = integer("id").autoIncrement().entityId() + override val primaryKey = PrimaryKey(id) + } + + class Names(id: EntityID) : IntR2dbcEntity(id) { + var first: String by NamesTable.first + var second: String by NamesTable.second + + companion object : IntR2dbcEntityClass(NamesTable) + } + + class Accounts(id: EntityID) : IntR2dbcEntity(id) { + val name by Names referencedOnSuspend AccountsTable.name + + companion object : R2dbcEntityClass(AccountsTable) { + + fun new(accountName: Pair): Accounts { + val newName = Names.new { + first = accountName.first + second = accountName.second + } + + return new { + this.name set newName + } + } + } + } + + @Test + fun testRegression() { + withTables(NamesTable, AccountsTable) { + val account = Accounts.new("first" to "second") + assertEquals("first", account.name().first) + assertEquals("second", account.name().second) + } + } +} + + +class `Text id loosed on insert issue 1379` : R2dbcDatabaseTestsBase() { + abstract class TextEntity(id: EntityID) : R2dbcEntity(id) + + abstract class TextEntityClass(table: IdTable, entityType: Class? = null) : R2dbcEntityClass(table, entityType) + + open class TextIdTable(name: String = "", columnName: String = "id") : IdTable(name) { + final override val id: Column> = text(columnName).entityId() + final override val primaryKey = PrimaryKey(id) + } + + class Obj1(id: EntityID) : LongR2dbcEntity(id) { + companion object : LongR2dbcEntityClass(Table1) + + var a by Table1.a + } + + class Obj2(id: EntityID) : TextEntity(id) { + companion object : TextEntityClass(Table2) + + var a by Table2.a + val ref by Obj1 referencedOnSuspend Table2.ref + } + + object Table2 : TextIdTable() { + val a = text("a") + val ref = reference("ref", Table1) + } + + object Table1 : LongIdTable() { + val a = text("a") + } + + @Test + fun testRegression() { + val runTests = TestDB.entries - TestDB.POSTGRESQL + withTables(runTests, Table1, Table2) { + val obj1 = Obj1.new { + a = "hello world!" + } + + Obj2.new("test") { + a = "bye world!" + ref set obj1 + } + } + } +} + + +class EntityCacheNotUpdatedOnCommitIssue1380 : R2dbcDatabaseTestsBase() { + object TestTable : IntIdTable() { + val value = integer("value") + } + + class TestEntity(id: EntityID) : IntR2dbcEntity(id) { + var value by TestTable.value + + companion object : IntR2dbcEntityClass(TestTable) + } + + @Test fun testRegression() { + withTables(TestTable) { + val entity1 = TestEntity.new { value = 1 } + + assertNotNull(TestEntity.findById(entity1.id)) + TestEntity.findById(entity1.id)?.delete() + commit() + // R2DBC: `Entity.delete()` short-circuits for un-flushed entities (no INSERT, no DELETE, + // no id generation), so `entity1.id._value` stays null and `findById` can't build a + // parametrised WHERE clause. Check the cache directly — that's the regression we care + // about (issue #1380: cache wasn't cleared on commit). + assertNull(TestEntity.testCache(entity1.id)) + } + } +} + +class AccessToPrimaryKeyFailsWithClassCastExceptionYT409 : R2dbcDatabaseTestsBase() { + + @Test + fun testCustomEntityIdColumnAccess() { + val tester = object : IdTable() { + + val value = varchar("value", 128) + + override val primaryKey: PrimaryKey = PrimaryKey(value) + override val id: Column> = value.entityId() + } + + withTables(tester) { + tester.insert { + it[tester.value] = "test-value" + } + val entry = tester.selectAll().first() + assertEquals("test-value", entry[tester.value]) + assertEquals("test-value", entry[tester.id].value) + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt index 3d5fa736c5..b9b68c7213 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntity.kt @@ -4,6 +4,7 @@ import org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException import org.jetbrains.exposed.r2dbc.dao.relationships.R2dbcInnerTableLink import org.jetbrains.exposed.v1.core.AutoIncColumnType import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.CompositeColumn import org.jetbrains.exposed.v1.core.EntityIDColumnType import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.Table @@ -75,6 +76,29 @@ open class R2dbcEntity(val id: EntityID) { } } + /** + * Property delegate for [CompositeColumn] — reads each underlying column's value via [Column.lookup] + * and reassembles them via [CompositeColumn.restoreValueFromParts]. Mirrors JDBC's `Entity` operator. + */ + operator fun CompositeColumn.getValue(o: R2dbcEntity, desc: KProperty<*>): T { + val values = this.getRealColumns().associateWith { it.lookup() } + return this.restoreValueFromParts(values) + } + + /** + * Property delegate for [CompositeColumn] — splits [value] into its real-column parts via + * [CompositeColumn.getRealColumnsWithValues] and writes each part through [Column.setValue]. + * Mirrors JDBC's `Entity` operator. + */ + operator fun CompositeColumn.setValue(o: R2dbcEntity, desc: KProperty<*>, value: T) { + with(o) { + this@setValue.getRealColumnsWithValues(value).forEach { (column, partValue) -> + @Suppress("UNCHECKED_CAST") + (column as Column).setValue(o, desc, partValue) + } + } + } + /** * Property delegate for [EntityFieldWithTransform] — reads the raw column value via [Column.getValue] * and runs it through the transformer's `wrap` function (with optional memoization). diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt index 61667f91ae..6b36632f15 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/R2dbcEntityClass.kt @@ -73,6 +73,13 @@ abstract class R2dbcEntityClass>( protected open fun createInstance(entityId: EntityID, row: ResultRow?): T = entityCtor(entityId) + /** + * Gets an [R2dbcEntity] by its raw [id] value. Mirrors JDBC's `EntityClass.findById(ID)` — + * wraps the value in an [EntityID] (or [CompositeID]-backed [R2dbcDaoEntityID]) before + * delegating to the [EntityID][findById] overload below. + */ + suspend fun findById(id: ID): T? = findById(R2dbcDaoEntityID(id, table)) + open suspend fun findById(id: EntityID): T? { val cached = testCache(id) if (cached != null) return cached @@ -267,41 +274,21 @@ abstract class R2dbcEntityClass>( } } - /** - * One-to-many reference. R2DBC counterpart of JDBC's `referrersOn`. - */ - // TODO ALIGN_WITH_JDBC: the relationship DSL is suffixed `*Suspend` (referrersOnSuspend, - // optionalReferrersOnSuspend, backReferencedOnSuspend, optionalBackReferencedOnSuspend) to - // disambiguate from the JDBC names that return entities directly. Once a unified naming - // scheme is agreed across modules, drop the suffix here. - @Suppress("UNCHECKED_CAST") - infix fun , REF> R2dbcEntityClass.referrersOnSuspend( - column: Column - ): R2dbcReferrers, TargetID, Target, REF> = - R2dbcReferrers(column, this, cache = true) - - /** Two-argument form to control caching behaviour. */ - fun , REF> R2dbcEntityClass.referrersOnSuspend( - column: Column, - cache: Boolean - ): R2dbcReferrers, TargetID, Target, REF> = - R2dbcReferrers(column, this, cache) - /** * Optional one-to-many reference. R2DBC counterpart of JDBC's `optionalReferrersOn`. */ infix fun , REF : Any> R2dbcEntityClass.optionalReferrersOnSuspend( - column: Column - ): R2dbcReferrers, TargetID, Target, REF?> = + column: Column + ): R2dbcReferrers, TargetID, Target, REF?> = R2dbcReferrers(column, this, cache = true) /** Two-argument form to control caching behaviour. */ fun , REF : Any> R2dbcEntityClass.optionalReferrersOnSuspend( - column: Column, - cache: Boolean - ): R2dbcReferrers, TargetID, Target, REF?> = + column: Column, + cache: Boolean + ): R2dbcReferrers, TargetID, Target, REF?> = R2dbcReferrers(column, this, cache) /** @@ -309,8 +296,8 @@ abstract class R2dbcEntityClass>( */ infix fun , REF> R2dbcEntityClass.backReferencedOnSuspend( - column: Column - ): R2dbcBackReference, REF> = + column: Column + ): R2dbcBackReference, REF> = R2dbcBackReference(column, this) /** @@ -318,8 +305,8 @@ abstract class R2dbcEntityClass>( */ infix fun , REF> R2dbcEntityClass.optionalBackReferencedOnSuspend( - column: Column - ): R2dbcOptionalBackReference, REF> = + column: Column + ): R2dbcOptionalBackReference, REF> = R2dbcOptionalBackReference(column, this) /** @@ -330,10 +317,79 @@ abstract class R2dbcEntityClass>( @JvmName("optionalBackReferencedOnSuspendNonNullable") infix fun , REF : Any> R2dbcEntityClass.optionalBackReferencedOnSuspend( - column: Column - ): R2dbcOptionalBackReference, REF> = + column: Column + ): R2dbcOptionalBackReference, REF> = R2dbcOptionalBackReference(column as Column, this) + @Suppress("UNCHECKED_CAST") + infix fun , REF> R2dbcEntityClass.referrersOnSuspend( + column: Column + ): R2dbcReferrers, TargetID, Target, REF> = + R2dbcReferrers(column, this, cache = true) + + /** Two-argument form to control caching behaviour. */ + fun , REF> + R2dbcEntityClass.referrersOnSuspend( + column: Column, + cache: Boolean + ): R2dbcReferrers, TargetID, Target, REF> = + R2dbcReferrers(column, this, cache) + + + /** Composite-FK form of [referrersOnSuspend]. R2DBC counterpart of JDBC's `referrersOn(IdTable<*>)`. */ + @Suppress("UNCHECKED_CAST") + infix fun > + R2dbcEntityClass.referrersOnSuspend( + table: IdTable<*> + ): R2dbcReferrers, TargetID, Target, Any> { + val tableFK = this@R2dbcEntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return R2dbcReferrers(delegate, this, cache = true, references = tableFK.references) + } + + /** Composite-FK form of [optionalReferrersOnSuspend]. R2DBC counterpart of JDBC's `optionalReferrersOn(IdTable<*>)`. */ + @Suppress("UNCHECKED_CAST") + infix fun > + R2dbcEntityClass.optionalReferrersOnSuspend( + table: IdTable<*> + ): R2dbcReferrers, TargetID, Target, Any?> { + val tableFK = this@R2dbcEntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return R2dbcReferrers(delegate, this, cache = true, references = tableFK.references) + } + + /** Composite-FK form of [backReferencedOnSuspend]. R2DBC counterpart of JDBC's `backReferencedOn(IdTable<*>)`. */ + @Suppress("UNCHECKED_CAST") + infix fun > + R2dbcEntityClass.backReferencedOnSuspend( + table: IdTable<*> + ): R2dbcBackReference, Any> { + val tableFK = this@R2dbcEntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return R2dbcBackReference(delegate, this, references = tableFK.references) + } + + /** Composite-FK form of [optionalBackReferencedOnSuspend]. R2DBC counterpart of JDBC's `optionalBackReferencedOn(IdTable<*>)`. */ + @Suppress("UNCHECKED_CAST") + infix fun > + R2dbcEntityClass.optionalBackReferencedOnSuspend( + table: IdTable<*> + ): R2dbcOptionalBackReference, Any> { + val tableFK = this@R2dbcEntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return R2dbcOptionalBackReference(delegate, this, references = tableFK.references) + } + + /** + * Returns the child table's [ForeignKeyConstraint] that matches the primary key columns defined on the table + * associated with this [R2dbcEntityClass]. Mirrors JDBC's `EntityClass.getCompositeForeignKey`. + */ + internal fun getCompositeForeignKey(table: IdTable<*>): ForeignKeyConstraint = + table.foreignKeys.firstOrNull { it.target == this.table.idColumns } + ?: error( + "Table ${table.tableName} does not hold a composite FK constraint matching ${this.table.tableName}'s primary key." + ) + @Suppress("UNCHECKED_CAST") suspend fun warmUpLinkedReferences( references: List>, diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt index c47a442d0f..d030da648c 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcBackReference.kt @@ -22,12 +22,14 @@ private suspend fun R2dbcEntity<*>.ensureIdFlushed() { class R2dbcBackReference, ChildID : Any, in Child : R2dbcEntity, REF>( reference: Column, - factory: R2dbcEntityClass + factory: R2dbcEntityClass, + references: Map, Column<*>>? = null ) { internal val delegate = R2dbcReferrers( reference, factory, - cache = true + cache = true, + references = references ) operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent { @@ -42,12 +44,14 @@ class R2dbcBackReference, Chi class R2dbcOptionalBackReference, ChildID : Any, in Child : R2dbcEntity, REF>( reference: Column, - factory: R2dbcEntityClass + factory: R2dbcEntityClass, + references: Map, Column<*>>? = null ) { internal val delegate = R2dbcReferrers( reference, factory, - cache = true + cache = true, + references = references ) operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent? { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt index 714ee86604..3207d3b6de 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcEagerLoading.kt @@ -7,7 +7,11 @@ import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.r2dbc.dao.flushCache import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.EntityIDColumnType +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.dao.id.CompositeID +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.inList import org.jetbrains.exposed.v1.r2dbc.LazySizedIterable import org.jetbrains.exposed.v1.r2dbc.SizedCollection @@ -124,6 +128,13 @@ private suspend fun List>.preloadReference( ): List> { val reference = accessor.reference val factory = accessor.factory + + // Composite-FK: fall back to a per-child `findById(CompositeID)` (N+1 but populates the + // entity cache; bulk fetch with `compoundOr` is left for a follow-up). + accessor.references?.let { refs -> + return preloadCompositeReference(this, factory, reference as Column, refs) + } + val refIds = mapNotNull { entity -> entity.lookupRefValue(reference) } if (refIds.isEmpty()) return emptyList() @@ -153,6 +164,11 @@ private suspend fun List>.preloadOptionalReference( ): List> { val reference = accessor.reference as Column val factory = accessor.factory + + accessor.references?.let { refs -> + return preloadCompositeReference(this, factory, reference, refs) + } + val refIds = mapNotNull { entity -> entity.lookupRefValue(reference) } val referee = reference.referee ?: return emptyList() @@ -178,6 +194,48 @@ private suspend fun List>.preloadOptionalReference( return loadedParents } +/** + * Composite-FK preload: iterate each child, build the composite parent id from its FK columns, + * and fetch via [R2dbcEntityClass.findById]. Each fetched parent is stored in the transaction's + * entity cache (by `findById`) and pinned on the child's per-entity reference cache. + * + * TODO ALIGN_WITH_JDBC: JDBC uses `warmUpOptByCompositeReferences` to bulk-fetch with one query + * (a compound OR of per-child AND clauses). Until that's ported we issue one query per child. + */ +@Suppress("UNCHECKED_CAST") +private suspend fun preloadCompositeReference( + children: List>, + factory: R2dbcEntityClass>, + reference: Column, + references: Map, Column<*>> +): List> { + val loaded = mutableListOf>() + children.forEach { child -> + // Collect raw FK column values up-front so we can short-circuit when any of them is null + // (CompositeID's builder rejects empty mappings, so we must avoid even constructing it). + val rawValues = references.map { (childColumn, parentColumn) -> + val raw = child.writeValues[childColumn as Column] + ?: child._readValues?.getOrNull(childColumn) + Triple(childColumn, parentColumn, raw) + } + if (rawValues.any { it.third == null }) { + child.storeReferenceInCache(reference, null) + return@forEach + } + val parentIdValue = CompositeID { id -> + rawValues.forEach { (childColumn, parentColumn, raw) -> + val pid = parentColumn as Column> + // Unwrap EntityID when child column stores a raw value. + id[pid] = if (raw is EntityID<*> && childColumn.columnType !is EntityIDColumnType<*>) raw._value!! else raw!! + } + } + val parent = factory.findById(parentIdValue as Any) ?: return@forEach + child.storeReferenceInCache(reference, parent) + loaded += parent + } + return loaded +} + private fun normalizeRefKey(value: Any): Any = (value as? EntityID<*>)?.value ?: value private fun List>.indexedByRefereeValue(referee: Column<*>): Map> { @@ -202,6 +260,15 @@ private suspend fun List>.preloadReferrers( ): List> { val refColumn = referrers.reference val factory = referrers.factory + val allReferences = referrers.allReferences + + // Composite-FK fall-through — fetch children per-parent with a compound AND condition. + // TODO ALIGN_WITH_JDBC: JDBC's `warmUpReferences` uses `compoundOr` of per-parent ANDs to do + // a single bulk query; we issue one query per parent here. + val isComposite = allReferences.size > 1 || allReferences.values.firstOrNull()?.table is CompositeIdTable + if (isComposite) { + return preloadCompositeReferrers(this, factory, refColumn, allReferences, referrers.getOrderByExpressions()) + } val referee = refColumn.referee ?: return emptyList() @@ -261,6 +328,58 @@ private suspend fun List>.preloadReferrers( return loadedChildren } +/** + * Composite-FK variant of [preloadReferrers]. For each parent in [parents], builds a per-parent + * compound AND condition over all child→parent FK pairs, fetches the matching children, and + * pins them into both the transaction's referrers cache and the parent's per-entity reference + * cache. Mirrors JDBC's composite branch in `Referrers.getValue` (References.kt:152–157). + * + * TODO ALIGN_WITH_JDBC: JDBC's `warmUpReferences` consolidates this into a single bulk query via + * `compoundOr` of per-parent ANDs. Until that's ported we issue one query per parent. + */ +@Suppress("UNCHECKED_CAST") +private suspend fun preloadCompositeReferrers( + parents: List>, + factory: R2dbcEntityClass>, + refColumn: Column<*>, + references: Map, Column<*>>, + orderBy: Array, org.jetbrains.exposed.v1.core.SortOrder>> +): List> { + val cache = TransactionManager.current().entityCache + val allLoaded = mutableListOf>() + + for (parent in parents) { + if (parent.id._value == null) continue + + // Build the per-child-column→parent-value map for this parent. + var anyNull = false + val childToParentValue: List, Any?>> = references.map { (childColumn, parentColumn) -> + val raw = parent.writeValues[parentColumn as Column] + ?: parent._readValues?.getOrNull(parentColumn) + if (raw == null) anyNull = true + val value = if (raw is EntityID<*> && childColumn.columnType !is EntityIDColumnType<*>) raw._value else raw + childColumn to value + } + if (anyNull) continue + + // Skip the fetch if the referrers cache slot is already populated for this parent. + if (cache.getReferrers>(parent.id, refColumn) != null) continue + + @Suppress("SpreadOperator") + val children = factory.find { + childToParentValue.map { (childColumn, value) -> + (childColumn as Column) eq value + }.reduce { acc, next -> acc and next } + }.orderBy(*orderBy).toList() + + cache.getOrPutReferrers(refColumn, parent.id) { SizedCollection(children) } + parent.storeReferenceInCache(refColumn, SizedCollection(children)) + allLoaded += children + } + + return allLoaded +} + private suspend fun List>.preloadInnerTableLink( accessor: R2dbcInnerTableLinkAccessor, Any, R2dbcEntity> ): List> { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt index 3d9ecf666c..e25246bd56 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcReferrers.kt @@ -7,6 +7,8 @@ import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.EntityIDColumnType import org.jetbrains.exposed.v1.core.Expression import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.dao.id.CompositeIdTable import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.r2dbc.SizedIterable @@ -17,7 +19,8 @@ import kotlin.reflect.KProperty class R2dbcReferrers, ChildID : Any, out Child : R2dbcEntity, REF>( val reference: Column, val factory: R2dbcEntityClass, - val cache: Boolean + val cache: Boolean, + references: Map, Column<*>>? = null ) { /** The set of columns and their [SortOrder] for ordering referred entities in one-to-many relationship. */ private val orderByExpressions = linkedSetOf, SortOrder>>() @@ -25,11 +28,23 @@ class R2dbcReferrers, ChildID /** Returns the order by expressions as an array. */ internal fun getOrderByExpressions(): Array, SortOrder>> = orderByExpressions.toTypedArray() - init { + /** + * Full child→parent column mapping for the relationship. Single-column references derive this from + * `reference.referee` lazily; composite-FK references pass the full map explicitly. Mirrors JDBC's + * `Referrers.allReferences`. + * + * TODO ALIGN_WITH_JDBC: not yet consumed at runtime — `getValue` below still issues a single-column + * `WHERE reference = value`. JDBC switches to `compoundAnd` of `eq`s when this map has multiple + * entries (see `References.kt`). + */ + @Suppress("unused") + val allReferences: Map, Column<*>> = references ?: run { reference.referee ?: error("Column $reference is not a reference") if (factory.table != reference.table) { error("Column $reference and factory ${factory.table.tableName} point to different tables") } + @Suppress("UNCHECKED_CAST") + mapOf(reference as Column<*> to reference.referee!!) } @Suppress("NestedBlockDepth", "SpreadOperator") @@ -58,18 +73,45 @@ class R2dbcReferrers, ChildID transaction.entityCache.flush() } - val referee = reference.referee!! - val refereeValue = with(thisRef) { referee.lookup() } + val isComposite = allReferences.size > 1 || allReferences.values.firstOrNull()?.table is CompositeIdTable + val query: suspend () -> SizedIterable = if (!isComposite) { + // Single-column referrers (original path). + val referee = reference.referee!! + val refereeValue = with(thisRef) { referee.lookup() } - val needsEntityIdUnwrap = reference.columnType !is EntityIDColumnType<*> && - referee.columnType is EntityIDColumnType<*> && refereeValue is EntityID<*> + val needsEntityIdUnwrap = reference.columnType !is EntityIDColumnType<*> && + referee.columnType is EntityIDColumnType<*> && refereeValue is EntityID<*> - @Suppress("UNCHECKED_CAST") - val refValue = if (needsEntityIdUnwrap) refereeValue.value as REF else refereeValue as REF + @Suppress("UNCHECKED_CAST") + val refValue = if (needsEntityIdUnwrap) refereeValue.value as REF else refereeValue as REF + ;{ + factory.find { reference eq refValue } + .orderBy(*orderByExpressions.toTypedArray()) + } + } else { + // Composite-FK referrers: build a compound AND of equalities, one per child→parent + // column pair. Mirrors JDBC's composite branch in `Referrers.getValue` + // (References.kt:149–156). + val parentValuesByChildColumn = allReferences.map { (childColumn, parentColumn) -> + @Suppress("UNCHECKED_CAST") + val parentValueRaw = with(thisRef) { (parentColumn as Column).lookup() } + // Unwrap EntityID when the child column stores a raw value. + val parentValue = if (parentValueRaw is EntityID<*> && childColumn.columnType !is EntityIDColumnType<*>) { + parentValueRaw._value + } else { + parentValueRaw + } + childColumn to parentValue + } - val query: suspend () -> SizedIterable = { - factory.find { reference eq refValue } - .orderBy(*orderByExpressions.toTypedArray()) + ;{ + factory.find { + @Suppress("UNCHECKED_CAST") + parentValuesByChildColumn.map { (childColumn, value) -> + (childColumn as Column) eq value + }.reduce { acc, next -> acc and next } + }.orderBy(*orderByExpressions.toTypedArray()) + } } val result = if (cache) { diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt index 9208f1a2d8..dd5ca00245 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/R2dbcRelationshipExtensions.kt @@ -3,29 +3,39 @@ package org.jetbrains.exposed.r2dbc.dao.relationships import org.jetbrains.exposed.r2dbc.dao.R2dbcEntity import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.IdTable import kotlin.reflect.KProperty class SuspendReference, REF : Any>( val reference: Column, - val factory: R2dbcEntityClass + val factory: R2dbcEntityClass, + /** + * Composite-FK child→parent column map (mirrors JDBC's `Reference.references`). `null` for + * single-column references — [SuspendAccessor] falls back to `reference.referee`. + */ + val references: Map, Column<*>>? = null ) { operator fun > provideDelegate( thisRef: SRC, property: KProperty<*> ): SuspendAccessor { - return SuspendAccessor(reference, factory, thisRef) + return SuspendAccessor(reference, factory, thisRef, references) } } class OptionalSuspendReference, REF : Any>( val reference: Column, - val factory: R2dbcEntityClass + val factory: R2dbcEntityClass, + /** + * Composite-FK child→parent column map (mirrors JDBC's `OptionalReference.references`). + */ + val references: Map, Column<*>>? = null ) { operator fun > provideDelegate( thisRef: SRC, property: KProperty<*> ): OptionalSuspendAccessor { - return OptionalSuspendAccessor(reference, factory, thisRef) + return OptionalSuspendAccessor(reference, factory, thisRef, references) } } @@ -44,3 +54,32 @@ infix fun , REF : Any> R2dbcEntityClass { return OptionalSuspendReference(reference, this) } + +/** + * Composite-FK form of [referencedOnSuspend]. R2DBC counterpart of JDBC's `referencedOn(IdTable<*>)`. + * + * Resolves the composite foreign-key constraint on [table] that points at this entity's primary key + * and binds the reference's first FK column as the delegate. (Full multi-column composite-FK + * resolution isn't implemented yet — see `R2dbcEntityClass.kt` TODO ALIGN_WITH_JDBC.) + */ +@Suppress("UNCHECKED_CAST") +infix fun > R2dbcEntityClass.referencedOnSuspend( + table: IdTable<*> +): SuspendReference { + val tableFK = getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return SuspendReference(delegate, this, references = tableFK.references) +} + +/** + * Composite-FK form of [optionalReferencedOnSuspend]. R2DBC counterpart of JDBC's + * `optionalReferencedOn(IdTable<*>)`. + */ +@Suppress("UNCHECKED_CAST") +infix fun > R2dbcEntityClass.optionalReferencedOnSuspend( + table: IdTable<*> +): OptionalSuspendReference { + val tableFK = getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return OptionalSuspendReference(delegate, this, references = tableFK.references) +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt index 819e2d0c3b..c6a1e21779 100644 --- a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/r2dbc/dao/relationships/SuspendAccessor.kt @@ -6,6 +6,7 @@ import org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass import org.jetbrains.exposed.r2dbc.dao.entityCache import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.EntityIDColumnType +import org.jetbrains.exposed.v1.core.dao.id.CompositeID import org.jetbrains.exposed.v1.core.dao.id.EntityID import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.eq @@ -16,7 +17,12 @@ import kotlin.reflect.KProperty class SuspendAccessor, REF : Any>( internal val reference: Column, internal val factory: R2dbcEntityClass, - internal val entity: R2dbcEntity<*> + internal val entity: R2dbcEntity<*>, + /** + * Composite-FK child→parent column map. `null` for single-column references — in that case + * [set] uses `reference.referee` directly. + */ + internal val references: Map, Column<*>>? = null ) { /** * getValue operator - returns this accessor which has invoke() and set operations. @@ -40,24 +46,27 @@ class SuspendAccessor, REF : Any>( error("Cannot link entities from different databases") } - // Store the reference value - extract from the referenced column - @Suppress("UNCHECKED_CAST") - val refValue = when { - reference.referee == factory.table.id -> { - // Reference points to the primary key - use the entity's ID - value.id as REF - } - reference.referee?.table == factory.table -> { - // Reference points to another column in the entity's table - val refereeColumn = reference.referee!! - // Try to get value from writeValues first, then readValues - (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + if (references != null) { + copyCompositeFkValues(entity, value, references) + } else { + // Single-column reference (original logic). + @Suppress("UNCHECKED_CAST") + val refValue = when { + reference.referee == factory.table.id -> { + // Reference points to the primary key - use the entity's ID + value.id as REF + } + reference.referee?.table == factory.table -> { + // Reference points to another column in the entity's table + val refereeColumn = reference.referee!! + // Try to get value from writeValues first, then readValues + (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") } - else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + entity.writeValues[reference as Column] = refValue } - entity.writeValues[reference as Column] = refValue - // Schedule update if entity has been flushed if (entity.id._value != null) { val entityCache = TransactionManager.current().entityCache @@ -80,6 +89,16 @@ class SuspendAccessor, REF : Any>( return entity.getReferenceFromCache(reference) } + if (references != null) { + // Composite-FK lookup — build a CompositeID from the child's columns mapped to the + // parent's referee columns, then `findById`. Mirrors JDBC's `Reference.getValue` + // composite branch (References.kt:152–161). + val parentEntity = lookupCompositeParent(factory, entity, references) + ?: error("Referenced entity not found for composite FK from ${reference.name}") + entity.storeReferenceInCache(reference, parentEntity) + return parentEntity + } + // TODO incapsulate this logic inside entity to avoid checking for different fields outside. @Suppress("UNCHECKED_CAST") val refValue: REF = (entity.writeValues[reference as Column] as? REF) @@ -98,7 +117,9 @@ class SuspendAccessor, REF : Any>( class OptionalSuspendAccessor, REF : Any>( internal val reference: Column, internal val factory: R2dbcEntityClass, - internal val entity: R2dbcEntity<*> + internal val entity: R2dbcEntity<*>, + /** Composite-FK child→parent column map. `null` for single-column references. */ + internal val references: Map, Column<*>>? = null ) { operator fun getValue(thisRef: Any?, property: KProperty<*>): OptionalSuspendAccessor { return this @@ -111,26 +132,31 @@ class OptionalSuspendAccessor, REF : Any>( error("Cannot link entities from different databases") } - // Store the reference value - extract from the referenced column - @Suppress("UNCHECKED_CAST") - val refValue = when { - reference.referee == factory.table.id -> { - // Reference points to the primary key - use the entity's ID - value.id as REF - } - reference.referee?.table == factory.table -> { - // Reference points to another column in the entity's table - val refereeColumn = reference.referee!! - // Try to get value from writeValues first, then readValues - (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + if (references != null) { + copyCompositeFkValues(entity, value, references) + } else { + @Suppress("UNCHECKED_CAST") + val refValue = when { + reference.referee == factory.table.id -> value.id as REF + reference.referee?.table == factory.table -> { + val refereeColumn = reference.referee!! + (value.writeValues[refereeColumn as Column] ?: value._readValues?.get(refereeColumn)) as REF + } + else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") } - else -> error("Reference column ${reference.name} does not point to any column in ${factory.table.tableName}") + entity.writeValues[reference as Column] = refValue } - - entity.writeValues[reference as Column] = refValue } else { - // Clear the reference - entity.writeValues[reference as Column] = null + if (references != null) { + // Clear every child column when clearing a composite reference. + references.keys.forEach { childColumn -> + @Suppress("UNCHECKED_CAST") + entity.writeValues[childColumn as Column] = null + } + } else { + // Clear the (single) reference + entity.writeValues[reference as Column] = null + } } // Schedule update if entity has been flushed @@ -154,6 +180,22 @@ class OptionalSuspendAccessor, REF : Any>( return entity.getReferenceFromCache(reference) } + if (references != null) { + // Composite-FK: if ANY of the FK columns is null on the child, the optional reference + // is considered absent (mirrors JDBC's CompositeID/null branch). + val anyNull = references.keys.any { childColumn -> + val v = entity.writeValues[childColumn as Column] ?: entity._readValues?.getOrNull(childColumn) + v == null + } + if (anyNull) { + entity.storeReferenceInCache(reference, null) + return null + } + val parentEntity = lookupCompositeParent(factory, entity, references) + entity.storeReferenceInCache(reference, parentEntity) + return parentEntity + } + @Suppress("UNCHECKED_CAST") val refValue: REF? = (entity.writeValues[reference as Column] as? REF) ?: (entity._readValues?.let { row -> row[reference] } as? REF) @@ -171,6 +213,55 @@ class OptionalSuspendAccessor, REF : Any>( } } + +private fun copyCompositeFkValues( + child: R2dbcEntity<*>, + parent: R2dbcEntity<*>, + references: Map, Column<*>> +) { + references.forEach { (childColumn, parentColumn) -> + @Suppress("UNCHECKED_CAST") + val parentRaw: Any? = parent.writeValues[parentColumn as Column] + ?: parent._readValues?.getOrNull(parentColumn) + // Unwrap `EntityID` when the child column stores a raw value + val value = if (parentRaw is EntityID<*> && childColumn.columnType !is EntityIDColumnType<*>) { + parentRaw._value + } else { + parentRaw + } + @Suppress("UNCHECKED_CAST") + child.writeValues[childColumn as Column] = value + } +} + +/** + * Composite-FK lookup used by [SuspendAccessor.invoke] / [OptionalSuspendAccessor.invoke] when the + * accessor was built from an `IdTable<*>`-shaped DSL entry point. Constructs a [CompositeID] by + * mapping each child column to its referee parent column, then delegates to `factory.findById`. + * + * Mirrors the `CompositeID` branch of JDBC's `Reference.getValue` (References.kt:157–161). + */ +@Suppress("UNCHECKED_CAST") +private suspend fun > lookupCompositeParent( + factory: R2dbcEntityClass, + child: R2dbcEntity<*>, + references: Map, Column<*>> +): Parent? { + val parentIdValue = CompositeID { id -> + references.forEach { (childColumn, parentColumn) -> + val rawChild = child.writeValues[childColumn as Column] + ?: child._readValues?.getOrNull(childColumn) + ?: error("Composite-FK child column ${childColumn.name} has no value on ${child.id}") + // `parentColumn` is an EntityID column on the parent's id table; wrap the raw child + // value into an `EntityID<*>` so `CompositeID` accepts it. + val parentIdColumn = parentColumn as Column> + val parentValueRaw = (rawChild as? EntityID<*>)?.value ?: rawChild + id[parentIdColumn] = parentValueRaw + } + } + return factory.findById(parentIdValue as ID) +} + /** * Shared lookup used by [SuspendAccessor.invoke] and [OptionalSuspendAccessor.invoke]. * diff --git a/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyDefaultsTest.kt b/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyDefaultsTest.kt index 38eb28e5ff..4593c3cec7 100644 --- a/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyDefaultsTest.kt +++ b/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyDefaultsTest.kt @@ -7,15 +7,12 @@ import org.jetbrains.exposed.v1.dao.IntEntity import org.jetbrains.exposed.v1.dao.IntEntityClass import org.jetbrains.exposed.v1.dao.flushCache import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.math.BigDecimal import kotlin.test.assertNull -@Tag(MISSING_R2DBC_TEST) class MoneyDefaultsTest : DatabaseTestsBase() { object TableWithDBDefault : IntIdTable() { diff --git a/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyTests.kt b/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyTests.kt index fb49051229..7d11ff3635 100644 --- a/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyTests.kt +++ b/exposed-money/src/test/kotlin/org/jetbrains/exposed/v1/money/MoneyTests.kt @@ -13,11 +13,9 @@ import org.jetbrains.exposed.v1.dao.IntEntity import org.jetbrains.exposed.v1.exceptions.ExposedSQLException import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEquals import org.jetbrains.exposed.v1.tests.shared.expectException -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import java.math.BigDecimal import javax.money.CurrencyUnit @@ -75,7 +73,6 @@ open class MoneyBaseTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testSearchByCompositeColumn() { val money = Money.of(BigDecimal.TEN, "USD") diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/CompositeIdTableEntityTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/CompositeIdTableEntityTest.kt index 9c16b9dc54..61922f034e 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/CompositeIdTableEntityTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/CompositeIdTableEntityTest.kt @@ -10,7 +10,7 @@ import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.inTopLevelTransaction import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST +import org.jetbrains.exposed.v1.tests.NO_R2DBC_SUPPORT import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.currentTestDB import org.jetbrains.exposed.v1.tests.shared.assertEqualCollections @@ -28,7 +28,6 @@ import kotlin.uuid.Uuid // SQLite excluded from most tests as it only allows auto-increment on single column PKs. // SQL Server is sometimes excluded because it doesn't allow inserting explicit values for identity columns. -@Tag(MISSING_R2DBC_TEST) class CompositeIdTableEntityTest : DatabaseTestsBase() { // CompositeIdTable with 2 key columns - int & uuid (both db-generated) object Publishers : CompositeIdTable("publishers") { @@ -474,6 +473,7 @@ class CompositeIdTableEntityTest : DatabaseTestsBase() { var population by Towns.population } + @Tag(NO_R2DBC_SUPPORT) @Test fun testCompositeIdTableWithSQLite() { withTables(excludeSettings = TestDB.ALL - TestDB.SQLITE, Towns) { diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityBugsRegressionTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityBugsRegressionTest.kt index dc69185f1c..67f892c080 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityBugsRegressionTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityBugsRegressionTest.kt @@ -16,15 +16,12 @@ import org.jetbrains.exposed.v1.dao.LongEntityClass import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.tests.DatabaseTestsBase -import org.jetbrains.exposed.v1.tests.MISSING_R2DBC_TEST import org.jetbrains.exposed.v1.tests.TestDB import org.jetbrains.exposed.v1.tests.shared.assertEquals -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import kotlin.test.assertNotNull import kotlin.test.assertNull -@Tag(MISSING_R2DBC_TEST) class `Table id not in Record Test issue 1341` : DatabaseTestsBase() { object NamesTable : IdTable("names_table") { From 74e7682cfc778e267e922a9bfa029782596d78cf Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Mon, 8 Jun 2026 10:26:16 +0200 Subject: [PATCH 7/7] feat: Exposed R2DBC DAO showcase --- build.gradle.kts | 8 +- gradle/libs.versions.toml | 11 +++ .../jdbc/build.gradle.kts | 35 +++++++ .../samples/broker/jdbc/Application.kt | 15 +++ .../broker/jdbc/model/InstrumentType.kt | 5 + .../samples/broker/jdbc/model/TradeType.kt | 5 + .../broker/jdbc/model/dto/BrokerDTOs.kt | 14 +++ .../broker/jdbc/model/dto/ClientDTOs.kt | 20 ++++ .../broker/jdbc/model/dto/InstrumentDTOs.kt | 15 +++ .../broker/jdbc/model/dto/PortfolioDTOs.kt | 14 +++ .../broker/jdbc/model/dto/TradeDTOs.kt | 28 ++++++ .../broker/jdbc/model/entities/Broker.kt | 17 ++++ .../broker/jdbc/model/entities/Client.kt | 20 ++++ .../broker/jdbc/model/entities/Instrument.kt | 18 ++++ .../broker/jdbc/model/entities/Portfolio.kt | 18 ++++ .../samples/broker/jdbc/model/entities/Tag.kt | 14 +++ .../broker/jdbc/model/entities/Trade.kt | 20 ++++ .../broker/jdbc/model/tables/Brokers.kt | 10 ++ .../broker/jdbc/model/tables/Clients.kt | 11 +++ .../jdbc/model/tables/InstrumentTags.kt | 11 +++ .../broker/jdbc/model/tables/Instruments.kt | 12 +++ .../broker/jdbc/model/tables/Portfolios.kt | 12 +++ .../samples/broker/jdbc/model/tables/Tags.kt | 9 ++ .../broker/jdbc/model/tables/Trades.kt | 17 ++++ .../samples/broker/jdbc/plugins/Database.kt | 23 +++++ .../samples/broker/jdbc/plugins/Routing.kt | 15 +++ .../broker/jdbc/plugins/Serialization.kt | 13 +++ .../broker/jdbc/routes/BrokerRoutes.kt | 65 +++++++++++++ .../broker/jdbc/routes/ClientRoutes.kt | 81 ++++++++++++++++ .../broker/jdbc/routes/InstrumentRoutes.kt | 92 ++++++++++++++++++ .../broker/jdbc/routes/PortfolioRoutes.kt | 61 ++++++++++++ .../samples/broker/jdbc/routes/SeedRoutes.kt | 64 +++++++++++++ .../samples/broker/jdbc/routes/TradeRoutes.kt | 54 +++++++++++ .../jdbc/src/main/resources/application.yaml | 6 ++ .../r2dbc/build.gradle.kts | 35 +++++++ .../samples/broker/r2dbc/Application.kt | 16 ++++ .../broker/r2dbc/model/InstrumentType.kt | 5 + .../samples/broker/r2dbc/model/TradeType.kt | 5 + .../broker/r2dbc/model/dto/BrokerDTOs.kt | 14 +++ .../broker/r2dbc/model/dto/ClientDTOs.kt | 20 ++++ .../broker/r2dbc/model/dto/InstrumentDTOs.kt | 15 +++ .../broker/r2dbc/model/dto/PortfolioDTOs.kt | 14 +++ .../broker/r2dbc/model/dto/TradeDTOs.kt | 28 ++++++ .../broker/r2dbc/model/entities/Broker.kt | 17 ++++ .../broker/r2dbc/model/entities/Client.kt | 21 ++++ .../broker/r2dbc/model/entities/Instrument.kt | 18 ++++ .../broker/r2dbc/model/entities/Portfolio.kt | 19 ++++ .../broker/r2dbc/model/entities/Tag.kt | 14 +++ .../broker/r2dbc/model/entities/Trade.kt | 22 +++++ .../broker/r2dbc/model/tables/Brokers.kt | 10 ++ .../broker/r2dbc/model/tables/Clients.kt | 11 +++ .../r2dbc/model/tables/InstrumentTags.kt | 11 +++ .../broker/r2dbc/model/tables/Instruments.kt | 12 +++ .../broker/r2dbc/model/tables/Portfolios.kt | 12 +++ .../samples/broker/r2dbc/model/tables/Tags.kt | 9 ++ .../broker/r2dbc/model/tables/Trades.kt | 17 ++++ .../samples/broker/r2dbc/plugins/Database.kt | 23 +++++ .../samples/broker/r2dbc/plugins/Routing.kt | 15 +++ .../broker/r2dbc/plugins/Serialization.kt | 14 +++ .../broker/r2dbc/routes/BrokerRoutes.kt | 68 +++++++++++++ .../broker/r2dbc/routes/ClientRoutes.kt | 82 ++++++++++++++++ .../broker/r2dbc/routes/InstrumentRoutes.kt | 96 +++++++++++++++++++ .../broker/r2dbc/routes/PortfolioRoutes.kt | 64 +++++++++++++ .../samples/broker/r2dbc/routes/SeedRoutes.kt | 71 ++++++++++++++ .../broker/r2dbc/routes/TradeRoutes.kt | 57 +++++++++++ .../r2dbc/src/main/resources/application.yaml | 6 ++ settings.gradle.kts | 4 + 67 files changed, 1675 insertions(+), 3 deletions(-) create mode 100644 samples/exposed-dao-showcase/jdbc/build.gradle.kts create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/Application.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/InstrumentType.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/TradeType.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/BrokerDTOs.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/ClientDTOs.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/InstrumentDTOs.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/PortfolioDTOs.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/TradeDTOs.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Broker.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Client.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Instrument.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Portfolio.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Tag.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Trade.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Brokers.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Clients.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/InstrumentTags.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Instruments.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Portfolios.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Tags.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Trades.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Database.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Routing.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Serialization.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/BrokerRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/ClientRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/InstrumentRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/PortfolioRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/SeedRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/TradeRoutes.kt create mode 100644 samples/exposed-dao-showcase/jdbc/src/main/resources/application.yaml create mode 100644 samples/exposed-dao-showcase/r2dbc/build.gradle.kts create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/Application.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/InstrumentType.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/TradeType.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/BrokerDTOs.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/ClientDTOs.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/InstrumentDTOs.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/PortfolioDTOs.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/TradeDTOs.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Broker.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Client.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Instrument.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Portfolio.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Tag.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Trade.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Brokers.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Clients.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/InstrumentTags.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Instruments.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Portfolios.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Tags.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Trades.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Database.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Routing.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Serialization.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/BrokerRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/ClientRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/InstrumentRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/PortfolioRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/SeedRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/TradeRoutes.kt create mode 100644 samples/exposed-dao-showcase/r2dbc/src/main/resources/application.yaml diff --git a/build.gradle.kts b/build.gradle.kts index 6048e33c48..e7817d3828 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -73,7 +73,8 @@ repositories { mavenCentral() } -val unpublishedProjects = setOf("exposed-tests", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc") +val sampleProjects = setOf("exposed-dao-showcase-jdbc", "exposed-dao-showcase-r2dbc") +val unpublishedProjects = setOf("exposed-tests", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc") + sampleProjects allprojects { if (this.name !in unpublishedProjects && this != rootProject) { @@ -91,10 +92,11 @@ allprojects { } apiValidation { - ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc")) + ignoredProjects.addAll(listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-dao-r2dbc-tests", "exposed-dao-r2dbc") + sampleProjects) } subprojects { + if (name in sampleProjects) return@subprojects configureDetekt() dependencies { @@ -103,7 +105,7 @@ subprojects { } subprojects { - if (name == "exposed-bom") return@subprojects + if (name == "exposed-bom" || name in sampleProjects) return@subprojects apply(plugin = rootProject.libs.plugins.jvm.get().pluginId) apply(plugin = rootProject.libs.plugins.kover.get().pluginId) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a2de55175..239a18cad2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,9 @@ testcontainers = "2.0.5" flyway = "12.6.0" +ktor = "3.3.2" +logback = "1.5.21" + [libraries] jvm = { group = "org.jetbrains.kotlin.jvm", name = "org.jetbrains.kotlin.jvm.gradle.plugin", version.ref = "kotlin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } @@ -132,6 +135,13 @@ flyway-oracle = { group = "org.flywaydb", name = "flyway-database-oracle", versi logcaptor = { group = "io.github.hakky54", name = "logcaptor", version.ref = "logcaptor" } +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } +ktor-server-config-yaml = { group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } + [plugins] dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -142,3 +152,4 @@ docker-compose = { id = "com.avast.gradle.docker-compose", version.ref = "docker kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } maven-publish = { id = "com.vanniktech.maven.publish" } gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradle-plugin-publish" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } diff --git a/samples/exposed-dao-showcase/jdbc/build.gradle.kts b/samples/exposed-dao-showcase/jdbc/build.gradle.kts new file mode 100644 index 0000000000..719afab44f --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + kotlin("jvm") + alias(libs.plugins.ktor) + alias(libs.plugins.serialization) +} + +group = "org.jetbrains.exposed.samples" +version = "0.0.1" + +application { + mainClass = "io.ktor.server.netty.EngineMain" +} + +dependencies { + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.config.yaml) + implementation(libs.ktor.serialization.kotlinx.json) + + implementation(project(":exposed-core")) + implementation(project(":exposed-jdbc")) + implementation(project(":exposed-dao")) + implementation(project(":exposed-kotlin-datetime")) + + implementation(libs.h2) + + implementation(libs.logback.classic) +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/Application.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/Application.kt new file mode 100644 index 0000000000..95d9929121 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/Application.kt @@ -0,0 +1,15 @@ +@file:Suppress("InvalidPackageDeclaration") +package org.jetbrains.exposed.samples.broker.jdbc + +import io.ktor.server.application.* +import org.jetbrains.exposed.samples.broker.jdbc.plugins.* + +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} + +fun Application.module() { + configureSerialization() + configureDatabase() + configureRouting() +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/InstrumentType.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/InstrumentType.kt new file mode 100644 index 0000000000..d43263de4c --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/InstrumentType.kt @@ -0,0 +1,5 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model + +enum class InstrumentType { STOCK, BOND, ETF } diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/TradeType.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/TradeType.kt new file mode 100644 index 0000000000..68840365ec --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/TradeType.kt @@ -0,0 +1,5 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model + +enum class TradeType { BUY, SELL } diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/BrokerDTOs.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/BrokerDTOs.kt new file mode 100644 index 0000000000..7968dbbf9b --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/BrokerDTOs.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class BrokerDTO(val id: Int? = null, val name: String, val licenseNumber: String) + +@Serializable +data class BrokerSummaryDTO(val id: Int, val name: String, val licenseNumber: String, val clientCount: Long) + +@Serializable +data class BrokerDetailDTO(val id: Int, val name: String, val licenseNumber: String, val clients: List) diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/ClientDTOs.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/ClientDTOs.kt new file mode 100644 index 0000000000..86080d1aac --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/ClientDTOs.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ClientDTO(val id: Int? = null, val name: String, val email: String, val brokerId: Int) + +@Serializable +data class ClientSummaryDTO(val id: Int, val name: String, val email: String) + +@Serializable +data class ClientDetailDTO( + val id: Int, + val name: String, + val email: String, + val broker: BrokerDTO, + val portfolios: List +) diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/InstrumentDTOs.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/InstrumentDTOs.kt new file mode 100644 index 0000000000..aaa9da3259 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/InstrumentDTOs.kt @@ -0,0 +1,15 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.dto + +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.samples.broker.jdbc.model.InstrumentType + +@Serializable +data class InstrumentDTO(val id: Int? = null, val ticker: String, val name: String, val type: InstrumentType) + +@Serializable +data class InstrumentDetailDTO(val id: Int, val ticker: String, val name: String, val type: InstrumentType, val tags: List) + +@Serializable +data class TagAssignmentDTO(val tags: List) diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/PortfolioDTOs.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/PortfolioDTOs.kt new file mode 100644 index 0000000000..7ccd45673e --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/PortfolioDTOs.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PortfolioDTO(val id: Int? = null, val name: String, val clientId: Int) + +@Serializable +data class PortfolioSummaryDTO(val id: Int, val name: String, val createdAt: String) + +@Serializable +data class PortfolioDetailDTO(val id: Int, val name: String, val createdAt: String, val trades: List) diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/TradeDTOs.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/TradeDTOs.kt new file mode 100644 index 0000000000..2a382f26fe --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/dto/TradeDTOs.kt @@ -0,0 +1,28 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.dto + +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.samples.broker.jdbc.model.TradeType + +@Serializable +data class TradeRequestDTO( + val clientId: Int, + val instrumentId: Int, + val portfolioId: Int? = null, + val type: TradeType, + val quantity: Int, + val price: String +) + +@Serializable +data class TradeDetailDTO( + val id: Int, + val instrumentTicker: String, + val instrumentName: String, + val type: TradeType, + val quantity: Int, + val price: String, + val executedAt: String, + val portfolioName: String? = null +) diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Broker.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Broker.kt new file mode 100644 index 0000000000..532bf4e8e1 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Broker.kt @@ -0,0 +1,17 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Brokers +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Clients +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Broker(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Brokers) + + var name by Brokers.name + var licenseNumber by Brokers.licenseNumber + val clients by Client referrersOn Clients.broker +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Client.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Client.kt new file mode 100644 index 0000000000..8216f6a071 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Client.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Clients +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Portfolios +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Client(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Clients) + + var name by Clients.name + var email by Clients.email + var broker by Broker referencedOn Clients.broker + val portfolios by Portfolio referrersOn Portfolios.client + val trades by Trade referrersOn Trades.client +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Instrument.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Instrument.kt new file mode 100644 index 0000000000..3a2978d475 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Instrument.kt @@ -0,0 +1,18 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.InstrumentTags +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Instruments +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Instrument(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Instruments) + + var ticker by Instruments.ticker + var name by Instruments.name + var type by Instruments.type + var tags by Tag via InstrumentTags +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Portfolio.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Portfolio.kt new file mode 100644 index 0000000000..eecb43594c --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Portfolio.kt @@ -0,0 +1,18 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Portfolios +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Portfolio(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Portfolios) + + var name by Portfolios.name + var client by Client referencedOn Portfolios.client + var createdAt by Portfolios.createdAt + val trades by Trade optionalReferrersOn Trades.portfolio +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Tag.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Tag.kt new file mode 100644 index 0000000000..96d67131ad --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Tag.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Tags +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Tag(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Tags) + + var name by Tags.name +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Trade.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Trade.kt new file mode 100644 index 0000000000..05be28a3fe --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/entities/Trade.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.entities + +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.IntEntity +import org.jetbrains.exposed.v1.dao.IntEntityClass + +class Trade(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Trades) + + var client by Client referencedOn Trades.client + var instrument by Instrument referencedOn Trades.instrument + var portfolio by Portfolio optionalReferencedOn Trades.portfolio + var type by Trades.type + var quantity by Trades.quantity + var price by Trades.price + var executedAt by Trades.executedAt +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Brokers.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Brokers.kt new file mode 100644 index 0000000000..0724bf451f --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Brokers.kt @@ -0,0 +1,10 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Brokers : IntIdTable("brokers") { + val name = varchar("name", 128) + val licenseNumber = varchar("license_number", 32).uniqueIndex() +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Clients.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Clients.kt new file mode 100644 index 0000000000..511f42f823 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Clients.kt @@ -0,0 +1,11 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Clients : IntIdTable("clients") { + val name = varchar("name", 128) + val email = varchar("email", 256).uniqueIndex() + val broker = reference("broker_id", Brokers) +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/InstrumentTags.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/InstrumentTags.kt new file mode 100644 index 0000000000..2028487388 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/InstrumentTags.kt @@ -0,0 +1,11 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.v1.core.Table + +object InstrumentTags : Table("instrument_tags") { + val instrument = reference("instrument_id", Instruments) + val tag = reference("tag_id", Tags) + override val primaryKey = PrimaryKey(instrument, tag) +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Instruments.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Instruments.kt new file mode 100644 index 0000000000..caad53642c --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Instruments.kt @@ -0,0 +1,12 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.samples.broker.jdbc.model.InstrumentType +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Instruments : IntIdTable("instruments") { + val ticker = varchar("ticker", 16).uniqueIndex() + val name = varchar("name", 256) + val type = enumerationByName("type", 16) +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Portfolios.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Portfolios.kt new file mode 100644 index 0000000000..f7e84f8b8b --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Portfolios.kt @@ -0,0 +1,12 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.datetime.timestamp + +object Portfolios : IntIdTable("portfolios") { + val name = varchar("name", 128) + val client = reference("client_id", Clients) + val createdAt = timestamp("created_at") +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Tags.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Tags.kt new file mode 100644 index 0000000000..aae679689a --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Tags.kt @@ -0,0 +1,9 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Tags : IntIdTable("tags") { + val name = varchar("name", 64).uniqueIndex() +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Trades.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Trades.kt new file mode 100644 index 0000000000..f56c7f4044 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/model/tables/Trades.kt @@ -0,0 +1,17 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.model.tables + +import org.jetbrains.exposed.samples.broker.jdbc.model.TradeType +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.datetime.timestamp + +object Trades : IntIdTable("trades") { + val client = reference("client_id", Clients) + val instrument = reference("instrument_id", Instruments) + val portfolio = optReference("portfolio_id", Portfolios) + val type = enumerationByName("type", 8) + val quantity = integer("quantity") + val price = decimal("price", 12, 4) + val executedAt = timestamp("executed_at") +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Database.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Database.kt new file mode 100644 index 0000000000..6b43436cfa --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Database.kt @@ -0,0 +1,23 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.plugins + +import io.ktor.server.application.* +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.* +import org.jetbrains.exposed.v1.dao.EntityHook +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.configureDatabase() { + Database.connect("jdbc:h2:mem:broker;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") + transaction { + SchemaUtils.create(Brokers, Clients, Portfolios, Instruments, Tags, InstrumentTags, Trades) + } + + EntityHook.subscribe { change -> + val table = change.entityClass.table.tableName + val action = change.changeType.name.lowercase() + log.info("Entity hook: $action on $table (id=${change.entityId})") + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Routing.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Routing.kt new file mode 100644 index 0000000000..7f982bdf86 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Routing.kt @@ -0,0 +1,15 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.plugins + +import io.ktor.server.application.* +import org.jetbrains.exposed.samples.broker.jdbc.routes.* + +fun Application.configureRouting() { + brokerRoutes() + clientRoutes() + instrumentRoutes() + portfolioRoutes() + tradeRoutes() + seedRoutes() +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Serialization.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Serialization.kt new file mode 100644 index 0000000000..a863e22527 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Serialization.kt @@ -0,0 +1,13 @@ +@file:Suppress("InvalidPackageDeclaration") +package org.jetbrains.exposed.samples.broker.jdbc.plugins + +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import kotlinx.serialization.json.Json + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json(Json { prettyPrint = true }) + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/BrokerRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/BrokerRoutes.kt new file mode 100644 index 0000000000..de4e93d6d5 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/BrokerRoutes.kt @@ -0,0 +1,65 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.samples.broker.jdbc.model.dto.* +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.v1.dao.load +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.brokerRoutes() { + routing { + route("/brokers") { + post { + val dto = call.receive() + val result = transaction { + val broker = Broker.new { + name = dto.name + licenseNumber = dto.licenseNumber + } + BrokerDTO(broker.id.value, broker.name, broker.licenseNumber) + } + call.respond(HttpStatusCode.Created, result) + } + + get { + val brokers = transaction { + Broker.all().map { broker -> + BrokerSummaryDTO( + id = broker.id.value, + name = broker.name, + licenseNumber = broker.licenseNumber, + clientCount = broker.clients.count() + ) + } + } + call.respond(brokers) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = transaction { + val broker = Broker.findById(id) + ?: return@transaction null + broker.load(Broker::clients) + BrokerDetailDTO( + id = broker.id.value, + name = broker.name, + licenseNumber = broker.licenseNumber, + clients = broker.clients.map { + ClientSummaryDTO(it.id.value, it.name, it.email) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Broker not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/ClientRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/ClientRoutes.kt new file mode 100644 index 0000000000..eb8f50e77e --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/ClientRoutes.kt @@ -0,0 +1,81 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.samples.broker.jdbc.model.dto.* +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.v1.dao.load +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.clientRoutes() { + routing { + route("/clients") { + post { + val dto = call.receive() + val result = transaction { + val broker = Broker.findById(dto.brokerId) + ?: error("Broker ${dto.brokerId} not found") + val client = Client.new { + name = dto.name + email = dto.email + this.broker = broker + } + ClientDTO(client.id.value, client.name, client.email, client.broker.id.value) + } + call.respond(HttpStatusCode.Created, result) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = transaction { + val client = Client.findById(id) + ?: return@transaction null + client.load(Client::broker, Client::portfolios) + ClientDetailDTO( + id = client.id.value, + name = client.name, + email = client.email, + broker = BrokerDTO( + client.broker.id.value, + client.broker.name, + client.broker.licenseNumber + ), + portfolios = client.portfolios.map { + PortfolioSummaryDTO(it.id.value, it.name, it.createdAt.toString()) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Client not found") + } + + get("{id}/trades") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val trades = transaction { + val client = Client.findById(id) ?: return@transaction null + client.trades.map { trade -> + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument.ticker, + instrumentName = trade.instrument.name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = trade.portfolio?.name + ) + } + } + if (trades != null) call.respond(trades) + else call.respond(HttpStatusCode.NotFound, "Client not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/InstrumentRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/InstrumentRoutes.kt new file mode 100644 index 0000000000..2f0af363f8 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/InstrumentRoutes.kt @@ -0,0 +1,92 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.samples.broker.jdbc.model.dto.* +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.samples.broker.jdbc.model.tables.Tags +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.dao.load +import org.jetbrains.exposed.v1.jdbc.SizedCollection +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.instrumentRoutes() { + routing { + route("/instruments") { + post { + val dto = call.receive() + val result = transaction { + val instrument = Instrument.new { + ticker = dto.ticker + name = dto.name + type = dto.type + } + InstrumentDTO(instrument.id.value, instrument.ticker, instrument.name, instrument.type) + } + call.respond(HttpStatusCode.Created, result) + } + + get { + val instruments = transaction { + Instrument.all().map { inst -> + InstrumentDetailDTO( + id = inst.id.value, + ticker = inst.ticker, + name = inst.name, + type = inst.type, + tags = inst.tags.map { it.name } + ) + } + } + call.respond(instruments) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = transaction { + val inst = Instrument.findById(id) ?: return@transaction null + inst.load(Instrument::tags) + InstrumentDetailDTO( + id = inst.id.value, + ticker = inst.ticker, + name = inst.name, + type = inst.type, + tags = inst.tags.map { it.name } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Instrument not found") + } + + put("{id}/tags") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@put call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val dto = call.receive() + val result = transaction { + val instrument = Instrument.findById(id) + ?: return@transaction null + val tags = dto.tags.map { tagName -> + Tag.find { Tags.name eq tagName }.firstOrNull() + ?: Tag.new { name = tagName } + } + instrument.tags = SizedCollection(tags) + InstrumentDetailDTO( + id = instrument.id.value, + ticker = instrument.ticker, + name = instrument.name, + type = instrument.type, + tags = instrument.tags.map { it.name } + ) + } + if (result != null) call.respond(result) + else call.respond(HttpStatusCode.NotFound, "Instrument not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/PortfolioRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/PortfolioRoutes.kt new file mode 100644 index 0000000000..f068591016 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/PortfolioRoutes.kt @@ -0,0 +1,61 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.samples.broker.jdbc.model.dto.* +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.portfolioRoutes() { + routing { + route("/portfolios") { + post { + val dto = call.receive() + val result = transaction { + val client = Client.findById(dto.clientId) + ?: error("Client ${dto.clientId} not found") + val portfolio = Portfolio.new { + name = dto.name + this.client = client + createdAt = Clock.System.now() + } + PortfolioDTO(portfolio.id.value, portfolio.name, portfolio.client.id.value) + } + call.respond(HttpStatusCode.Created, result) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = transaction { + val portfolio = Portfolio.findById(id) ?: return@transaction null + PortfolioDetailDTO( + id = portfolio.id.value, + name = portfolio.name, + createdAt = portfolio.createdAt.toString(), + trades = portfolio.trades.map { trade -> + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument.ticker, + instrumentName = trade.instrument.name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = portfolio.name + ) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Portfolio not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/SeedRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/SeedRoutes.kt new file mode 100644 index 0000000000..d95f8a7abe --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/SeedRoutes.kt @@ -0,0 +1,64 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.samples.broker.jdbc.model.InstrumentType +import org.jetbrains.exposed.samples.broker.jdbc.model.TradeType +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.v1.jdbc.SizedCollection +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.seedRoutes() { + routing { + post("/seed") { + transaction { + val tagTech = Tag.new { name = "tech" } + val tagFinance = Tag.new { name = "finance" } + val tagEnergy = Tag.new { name = "energy" } + val tagIndex = Tag.new { name = "index" } + + val aapl = Instrument.new { ticker = "AAPL"; name = "Apple Inc."; type = InstrumentType.STOCK } + val googl = Instrument.new { ticker = "GOOGL"; name = "Alphabet Inc."; type = InstrumentType.STOCK } + val tsla = Instrument.new { ticker = "TSLA"; name = "Tesla Inc."; type = InstrumentType.STOCK } + val spy = Instrument.new { ticker = "SPY"; name = "S&P 500 ETF"; type = InstrumentType.ETF } + val bnd = Instrument.new { ticker = "BND"; name = "Total Bond Market ETF"; type = InstrumentType.BOND } + val xom = Instrument.new { ticker = "XOM"; name = "Exxon Mobil"; type = InstrumentType.STOCK } + + aapl.tags = SizedCollection(listOf(tagTech)) + googl.tags = SizedCollection(listOf(tagTech)) + tsla.tags = SizedCollection(listOf(tagTech, tagEnergy)) + spy.tags = SizedCollection(listOf(tagIndex, tagFinance)) + bnd.tags = SizedCollection(listOf(tagFinance)) + xom.tags = SizedCollection(listOf(tagEnergy)) + + val brokerA = Broker.new { name = "Alpha Securities"; licenseNumber = "SEC-001" } + val brokerB = Broker.new { name = "Beta Trading"; licenseNumber = "SEC-002" } + + val alice = Client.new { name = "Alice Johnson"; email = "alice@example.com"; broker = brokerA } + val bob = Client.new { name = "Bob Smith"; email = "bob@example.com"; broker = brokerA } + val carol = Client.new { name = "Carol White"; email = "carol@example.com"; broker = brokerB } + val dave = Client.new { name = "Dave Brown"; email = "dave@example.com"; broker = brokerB } + + val aliceGrowth = Portfolio.new { name = "Growth Portfolio"; client = alice; createdAt = Clock.System.now() } + val aliceSafe = Portfolio.new { name = "Conservative Portfolio"; client = alice; createdAt = Clock.System.now() } + val bobMain = Portfolio.new { name = "Main Portfolio"; client = bob; createdAt = Clock.System.now() } + val carolTech = Portfolio.new { name = "Tech Portfolio"; client = carol; createdAt = Clock.System.now() } + + val now = Clock.System.now() + Trade.new { client = alice; instrument = aapl; portfolio = aliceGrowth; type = TradeType.BUY; quantity = 100; price = "178.50".toBigDecimal(); executedAt = now } + Trade.new { client = alice; instrument = tsla; portfolio = aliceGrowth; type = TradeType.BUY; quantity = 50; price = "242.00".toBigDecimal(); executedAt = now } + Trade.new { client = alice; instrument = bnd; portfolio = aliceSafe; type = TradeType.BUY; quantity = 200; price = "72.30".toBigDecimal(); executedAt = now } + Trade.new { client = bob; instrument = spy; portfolio = bobMain; type = TradeType.BUY; quantity = 150; price = "450.00".toBigDecimal(); executedAt = now } + Trade.new { client = carol; instrument = googl; portfolio = carolTech; type = TradeType.BUY; quantity = 30; price = "141.80".toBigDecimal(); executedAt = now } + Trade.new { client = dave; instrument = xom; portfolio = null; type = TradeType.BUY; quantity = 75; price = "105.20".toBigDecimal(); executedAt = now } + } + + call.respond(HttpStatusCode.Created, mapOf("status" to "Seed data created")) + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/TradeRoutes.kt b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/TradeRoutes.kt new file mode 100644 index 0000000000..10fffa6d6b --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/TradeRoutes.kt @@ -0,0 +1,54 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.jdbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.samples.broker.jdbc.model.dto.* +import org.jetbrains.exposed.samples.broker.jdbc.model.entities.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +fun Application.tradeRoutes() { + routing { + route("/trades") { + post { + val dto = call.receive() + val result = transaction { + val client = Client.findById(dto.clientId) + ?: error("Client ${dto.clientId} not found") + val instrument = Instrument.findById(dto.instrumentId) + ?: error("Instrument ${dto.instrumentId} not found") + val portfolio = dto.portfolioId?.let { + Portfolio.findById(it) ?: error("Portfolio $it not found") + } + + val trade = Trade.new { + this.client = client + this.instrument = instrument + this.portfolio = portfolio + this.type = dto.type + this.quantity = dto.quantity + this.price = dto.price.toBigDecimal() + this.executedAt = Clock.System.now() + } + + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument.ticker, + instrumentName = trade.instrument.name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = trade.portfolio?.name + ) + } + call.respond(HttpStatusCode.Created, result) + } + } + } +} diff --git a/samples/exposed-dao-showcase/jdbc/src/main/resources/application.yaml b/samples/exposed-dao-showcase/jdbc/src/main/resources/application.yaml new file mode 100644 index 0000000000..49b35b22ac --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +ktor: + application: + modules: + - org.jetbrains.exposed.samples.broker.jdbc.ApplicationKt.module + deployment: + port: 8080 diff --git a/samples/exposed-dao-showcase/r2dbc/build.gradle.kts b/samples/exposed-dao-showcase/r2dbc/build.gradle.kts new file mode 100644 index 0000000000..c7deab2928 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + kotlin("jvm") + alias(libs.plugins.ktor) + alias(libs.plugins.serialization) +} + +group = "org.jetbrains.exposed.samples" +version = "0.0.1" + +application { + mainClass = "io.ktor.server.netty.EngineMain" +} + +dependencies { + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.config.yaml) + implementation(libs.ktor.serialization.kotlinx.json) + + implementation(project(":exposed-core")) + implementation(project(":exposed-r2dbc")) + implementation(project(":exposed-dao-r2dbc")) + implementation(project(":exposed-kotlin-datetime")) + + implementation(libs.r2dbc.h2) + + implementation(libs.logback.classic) +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/Application.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/Application.kt new file mode 100644 index 0000000000..9f7655b617 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/Application.kt @@ -0,0 +1,16 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc + +import io.ktor.server.application.* +import org.jetbrains.exposed.samples.broker.r2dbc.plugins.* + +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} + +suspend fun Application.module() { + configureSerialization() + configureDatabase() + configureRouting() +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/InstrumentType.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/InstrumentType.kt new file mode 100644 index 0000000000..3581aaa54c --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/InstrumentType.kt @@ -0,0 +1,5 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model + +enum class InstrumentType { STOCK, BOND, ETF } diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/TradeType.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/TradeType.kt new file mode 100644 index 0000000000..e0cc12d8ca --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/TradeType.kt @@ -0,0 +1,5 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model + +enum class TradeType { BUY, SELL } diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/BrokerDTOs.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/BrokerDTOs.kt new file mode 100644 index 0000000000..9ba78034eb --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/BrokerDTOs.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class BrokerDTO(val id: Int? = null, val name: String, val licenseNumber: String) + +@Serializable +data class BrokerSummaryDTO(val id: Int, val name: String, val licenseNumber: String, val clientCount: Long) + +@Serializable +data class BrokerDetailDTO(val id: Int, val name: String, val licenseNumber: String, val clients: List) diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/ClientDTOs.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/ClientDTOs.kt new file mode 100644 index 0000000000..8bc73e0138 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/ClientDTOs.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ClientDTO(val id: Int? = null, val name: String, val email: String, val brokerId: Int) + +@Serializable +data class ClientSummaryDTO(val id: Int, val name: String, val email: String) + +@Serializable +data class ClientDetailDTO( + val id: Int, + val name: String, + val email: String, + val broker: BrokerDTO, + val portfolios: List +) diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/InstrumentDTOs.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/InstrumentDTOs.kt new file mode 100644 index 0000000000..5158027c7b --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/InstrumentDTOs.kt @@ -0,0 +1,15 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.dto + +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.samples.broker.r2dbc.model.InstrumentType + +@Serializable +data class InstrumentDTO(val id: Int? = null, val ticker: String, val name: String, val type: InstrumentType) + +@Serializable +data class InstrumentDetailDTO(val id: Int, val ticker: String, val name: String, val type: InstrumentType, val tags: List) + +@Serializable +data class TagAssignmentDTO(val tags: List) diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/PortfolioDTOs.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/PortfolioDTOs.kt new file mode 100644 index 0000000000..93ac0ed133 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/PortfolioDTOs.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PortfolioDTO(val id: Int? = null, val name: String, val clientId: Int) + +@Serializable +data class PortfolioSummaryDTO(val id: Int, val name: String, val createdAt: String) + +@Serializable +data class PortfolioDetailDTO(val id: Int, val name: String, val createdAt: String, val trades: List) diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/TradeDTOs.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/TradeDTOs.kt new file mode 100644 index 0000000000..89acb0dda2 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/dto/TradeDTOs.kt @@ -0,0 +1,28 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.dto + +import kotlinx.serialization.Serializable +import org.jetbrains.exposed.samples.broker.r2dbc.model.TradeType + +@Serializable +data class TradeRequestDTO( + val clientId: Int, + val instrumentId: Int, + val portfolioId: Int? = null, + val type: TradeType, + val quantity: Int, + val price: String +) + +@Serializable +data class TradeDetailDTO( + val id: Int, + val instrumentTicker: String, + val instrumentName: String, + val type: TradeType, + val quantity: Int, + val price: String, + val executedAt: String, + val portfolioName: String? = null +) diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Broker.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Broker.kt new file mode 100644 index 0000000000..b53515aeb9 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Broker.kt @@ -0,0 +1,17 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Brokers +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Clients +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Broker(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Brokers) + + var name by Brokers.name + var licenseNumber by Brokers.licenseNumber + val clients by Client referrersOnSuspend Clients.broker +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Client.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Client.kt new file mode 100644 index 0000000000..67cf18faa6 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Client.kt @@ -0,0 +1,21 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Clients +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Portfolios +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Client(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Clients) + + var name by Clients.name + var email by Clients.email + val broker by Broker referencedOnSuspend Clients.broker + val portfolios by Portfolio referrersOnSuspend Portfolios.client + val trades by Trade referrersOnSuspend Trades.client +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Instrument.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Instrument.kt new file mode 100644 index 0000000000..4dbd97024b --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Instrument.kt @@ -0,0 +1,18 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.InstrumentTags +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Instruments +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Instrument(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Instruments) + + var ticker by Instruments.ticker + var name by Instruments.name + var type by Instruments.type + val tags by Tag viaSuspend InstrumentTags +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Portfolio.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Portfolio.kt new file mode 100644 index 0000000000..086b4afad9 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Portfolio.kt @@ -0,0 +1,19 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Portfolios +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Portfolio(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Portfolios) + + var name by Portfolios.name + val client by Client referencedOnSuspend Portfolios.client + var createdAt by Portfolios.createdAt + val trades by Trade optionalReferrersOnSuspend Trades.portfolio +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Tag.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Tag.kt new file mode 100644 index 0000000000..d50d20a6e0 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Tag.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Tags +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Tag(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Tags) + + var name by Tags.name +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Trade.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Trade.kt new file mode 100644 index 0000000000..b6d314d2f0 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Trade.kt @@ -0,0 +1,22 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntity +import org.jetbrains.exposed.r2dbc.dao.IntR2dbcEntityClass +import org.jetbrains.exposed.r2dbc.dao.relationships.optionalReferencedOnSuspend +import org.jetbrains.exposed.r2dbc.dao.relationships.referencedOnSuspend +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID + +class Trade(id: EntityID) : IntR2dbcEntity(id) { + companion object : IntR2dbcEntityClass(Trades) + + val client by Client referencedOnSuspend Trades.client + val instrument by Instrument referencedOnSuspend Trades.instrument + val portfolio by Portfolio optionalReferencedOnSuspend Trades.portfolio + var type by Trades.type + var quantity by Trades.quantity + var price by Trades.price + var executedAt by Trades.executedAt +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Brokers.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Brokers.kt new file mode 100644 index 0000000000..8abcd6b634 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Brokers.kt @@ -0,0 +1,10 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Brokers : IntIdTable("brokers") { + val name = varchar("name", 128) + val licenseNumber = varchar("license_number", 32).uniqueIndex() +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Clients.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Clients.kt new file mode 100644 index 0000000000..5763f4ebea --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Clients.kt @@ -0,0 +1,11 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Clients : IntIdTable("clients") { + val name = varchar("name", 128) + val email = varchar("email", 256).uniqueIndex() + val broker = reference("broker_id", Brokers) +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/InstrumentTags.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/InstrumentTags.kt new file mode 100644 index 0000000000..098169aac1 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/InstrumentTags.kt @@ -0,0 +1,11 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.v1.core.Table + +object InstrumentTags : Table("instrument_tags") { + val instrument = reference("instrument_id", Instruments) + val tag = reference("tag_id", Tags) + override val primaryKey = PrimaryKey(instrument, tag) +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Instruments.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Instruments.kt new file mode 100644 index 0000000000..5b37bc5a3b --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Instruments.kt @@ -0,0 +1,12 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.samples.broker.r2dbc.model.InstrumentType +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Instruments : IntIdTable("instruments") { + val ticker = varchar("ticker", 16).uniqueIndex() + val name = varchar("name", 256) + val type = enumerationByName("type", 16) +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Portfolios.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Portfolios.kt new file mode 100644 index 0000000000..1de021496a --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Portfolios.kt @@ -0,0 +1,12 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.datetime.timestamp + +object Portfolios : IntIdTable("portfolios") { + val name = varchar("name", 128) + val client = reference("client_id", Clients) + val createdAt = timestamp("created_at") +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Tags.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Tags.kt new file mode 100644 index 0000000000..41a22febfc --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Tags.kt @@ -0,0 +1,9 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable + +object Tags : IntIdTable("tags") { + val name = varchar("name", 64).uniqueIndex() +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Trades.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Trades.kt new file mode 100644 index 0000000000..ae3bce4ecc --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Trades.kt @@ -0,0 +1,17 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.tables + +import org.jetbrains.exposed.samples.broker.r2dbc.model.TradeType +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.datetime.timestamp + +object Trades : IntIdTable("trades") { + val client = reference("client_id", Clients) + val instrument = reference("instrument_id", Instruments) + val portfolio = optReference("portfolio_id", Portfolios) + val type = enumerationByName("type", 8) + val quantity = integer("quantity") + val price = decimal("price", 12, 4) + val executedAt = timestamp("executed_at") +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Database.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Database.kt new file mode 100644 index 0000000000..78ddf7c317 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Database.kt @@ -0,0 +1,23 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.plugins + +import io.ktor.server.application.* +import org.jetbrains.exposed.r2dbc.dao.EntityHook +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.* +import org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +suspend fun Application.configureDatabase() { + R2dbcDatabase.connect("r2dbc:h2:mem:///broker;DB_CLOSE_DELAY=-1") + suspendTransaction { + SchemaUtils.create(Brokers, Clients, Portfolios, Instruments, Tags, InstrumentTags, Trades) + } + + EntityHook.subscribe { change -> + val table = change.entityClass.table.tableName + val action = change.changeType.name.lowercase() + log.info("Entity hook: $action on $table (id=${change.entityId})") + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Routing.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Routing.kt new file mode 100644 index 0000000000..6351f05ff1 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Routing.kt @@ -0,0 +1,15 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.plugins + +import io.ktor.server.application.* +import org.jetbrains.exposed.samples.broker.r2dbc.routes.* + +fun Application.configureRouting() { + brokerRoutes() + clientRoutes() + instrumentRoutes() + portfolioRoutes() + tradeRoutes() + seedRoutes() +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Serialization.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Serialization.kt new file mode 100644 index 0000000000..cc501fc996 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/plugins/Serialization.kt @@ -0,0 +1,14 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.plugins + +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import kotlinx.serialization.json.Json + +fun Application.configureSerialization() { + install(ContentNegotiation) { + json(Json { prettyPrint = true }) + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/BrokerRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/BrokerRoutes.kt new file mode 100644 index 0000000000..405d7e7133 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/BrokerRoutes.kt @@ -0,0 +1,68 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import kotlinx.coroutines.flow.toList +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.brokerRoutes() { + routing { + route("/brokers") { + post { + val dto = call.receive() + val result = suspendTransaction { + val broker = Broker.new { + name = dto.name + licenseNumber = dto.licenseNumber + } + flushCache() + BrokerDTO(broker.id.value, broker.name, broker.licenseNumber) + } + call.respond(HttpStatusCode.Created, result) + } + + get { + val brokers = suspendTransaction { + Broker.all().toList().map { broker -> + BrokerSummaryDTO( + id = broker.id.value, + name = broker.name, + licenseNumber = broker.licenseNumber, + clientCount = broker.clients().count() + ) + } + } + call.respond(brokers) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = suspendTransaction { + val broker = Broker.findById(id) + ?: return@suspendTransaction null + broker.load(Broker::clients) + BrokerDetailDTO( + id = broker.id.value, + name = broker.name, + licenseNumber = broker.licenseNumber, + clients = broker.clients().toList().map { + ClientSummaryDTO(it.id.value, it.name, it.email) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Broker not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/ClientRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/ClientRoutes.kt new file mode 100644 index 0000000000..7f3f295402 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/ClientRoutes.kt @@ -0,0 +1,82 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import kotlinx.coroutines.flow.toList +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.clientRoutes() { + routing { + route("/clients") { + post { + val dto = call.receive() + val result = suspendTransaction { + val broker = Broker.findById(dto.brokerId) + ?: error("Broker ${dto.brokerId} not found") + val client = Client.new { + name = dto.name + email = dto.email + this.broker set broker + } + flushCache() + ClientDTO(client.id.value, client.name, client.email, client.broker().id.value) + } + call.respond(HttpStatusCode.Created, result) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = suspendTransaction { + val client = Client.findById(id) + ?: return@suspendTransaction null + client.load(Client::broker, Client::portfolios) + ClientDetailDTO( + id = client.id.value, + name = client.name, + email = client.email, + broker = client.broker().let { b -> + BrokerDTO(b.id.value, b.name, b.licenseNumber) + }, + portfolios = client.portfolios().toList().map { + PortfolioSummaryDTO(it.id.value, it.name, it.createdAt.toString()) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Client not found") + } + + get("{id}/trades") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val trades = suspendTransaction { + val client = Client.findById(id) ?: return@suspendTransaction null + client.trades().toList().map { trade -> + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument().ticker, + instrumentName = trade.instrument().name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = trade.portfolio()?.name + ) + } + } + if (trades != null) call.respond(trades) + else call.respond(HttpStatusCode.NotFound, "Client not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/InstrumentRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/InstrumentRoutes.kt new file mode 100644 index 0000000000..c91b51286e --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/InstrumentRoutes.kt @@ -0,0 +1,96 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.toList +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.r2dbc.dao.relationships.load +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Tags +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.instrumentRoutes() { + routing { + route("/instruments") { + post { + val dto = call.receive() + val result = suspendTransaction { + val instrument = Instrument.new { + ticker = dto.ticker + name = dto.name + type = dto.type + } + flushCache() + InstrumentDTO(instrument.id.value, instrument.ticker, instrument.name, instrument.type) + } + call.respond(HttpStatusCode.Created, result) + } + + get { + val instruments = suspendTransaction { + Instrument.all().toList().map { inst -> + InstrumentDetailDTO( + id = inst.id.value, + ticker = inst.ticker, + name = inst.name, + type = inst.type, + tags = inst.tags().toList().map { it.name } + ) + } + } + call.respond(instruments) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = suspendTransaction { + val inst = Instrument.findById(id) ?: return@suspendTransaction null + inst.load(Instrument::tags) + InstrumentDetailDTO( + id = inst.id.value, + ticker = inst.ticker, + name = inst.name, + type = inst.type, + tags = inst.tags().toList().map { it.name } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Instrument not found") + } + + put("{id}/tags") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@put call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val dto = call.receive() + val result = suspendTransaction { + val instrument = Instrument.findById(id) + ?: return@suspendTransaction null + val tags = dto.tags.map { tagName -> + Tag.find { Tags.name eq tagName }.firstOrNull() + ?: Tag.new { name = tagName }.also { flushCache() } + } + instrument.tags set tags + flushCache() + InstrumentDetailDTO( + id = instrument.id.value, + ticker = instrument.ticker, + name = instrument.name, + type = instrument.type, + tags = instrument.tags().toList().map { it.name } + ) + } + if (result != null) call.respond(result) + else call.respond(HttpStatusCode.NotFound, "Instrument not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/PortfolioRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/PortfolioRoutes.kt new file mode 100644 index 0000000000..db42df168a --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/PortfolioRoutes.kt @@ -0,0 +1,64 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import kotlinx.coroutines.flow.toList +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.portfolioRoutes() { + routing { + route("/portfolios") { + post { + val dto = call.receive() + val result = suspendTransaction { + val client = Client.findById(dto.clientId) + ?: error("Client ${dto.clientId} not found") + val portfolio = Portfolio.new { + name = dto.name + this.client set client + createdAt = Clock.System.now() + } + flushCache() + PortfolioDTO(portfolio.id.value, portfolio.name, portfolio.client().id.value) + } + call.respond(HttpStatusCode.Created, result) + } + + get("{id}") { + val id = call.parameters["id"]?.toIntOrNull() + ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID") + val detail = suspendTransaction { + val portfolio = Portfolio.findById(id) ?: return@suspendTransaction null + PortfolioDetailDTO( + id = portfolio.id.value, + name = portfolio.name, + createdAt = portfolio.createdAt.toString(), + trades = portfolio.trades().toList().map { trade -> + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument().ticker, + instrumentName = trade.instrument().name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = portfolio.name + ) + } + ) + } + if (detail != null) call.respond(detail) + else call.respond(HttpStatusCode.NotFound, "Portfolio not found") + } + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/SeedRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/SeedRoutes.kt new file mode 100644 index 0000000000..91e716f9df --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/SeedRoutes.kt @@ -0,0 +1,71 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.samples.broker.r2dbc.model.InstrumentType +import org.jetbrains.exposed.samples.broker.r2dbc.model.TradeType +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.seedRoutes() { + routing { + post("/seed") { + suspendTransaction { + val tagTech = Tag.new { name = "tech" } + val tagFinance = Tag.new { name = "finance" } + val tagEnergy = Tag.new { name = "energy" } + val tagIndex = Tag.new { name = "index" } + flushCache() + + val aapl = Instrument.new { ticker = "AAPL"; name = "Apple Inc."; type = InstrumentType.STOCK } + val googl = Instrument.new { ticker = "GOOGL"; name = "Alphabet Inc."; type = InstrumentType.STOCK } + val tsla = Instrument.new { ticker = "TSLA"; name = "Tesla Inc."; type = InstrumentType.STOCK } + val spy = Instrument.new { ticker = "SPY"; name = "S&P 500 ETF"; type = InstrumentType.ETF } + val bnd = Instrument.new { ticker = "BND"; name = "Total Bond Market ETF"; type = InstrumentType.BOND } + val xom = Instrument.new { ticker = "XOM"; name = "Exxon Mobil"; type = InstrumentType.STOCK } + flushCache() + + aapl.tags set listOf(tagTech) + googl.tags set listOf(tagTech) + tsla.tags set listOf(tagTech, tagEnergy) + spy.tags set listOf(tagIndex, tagFinance) + bnd.tags set listOf(tagFinance) + xom.tags set listOf(tagEnergy) + flushCache() + + val brokerA = Broker.new { name = "Alpha Securities"; licenseNumber = "SEC-001" } + val brokerB = Broker.new { name = "Beta Trading"; licenseNumber = "SEC-002" } + flushCache() + + val alice = Client.new { name = "Alice Johnson"; email = "alice@example.com"; broker set brokerA } + val bob = Client.new { name = "Bob Smith"; email = "bob@example.com"; broker set brokerA } + val carol = Client.new { name = "Carol White"; email = "carol@example.com"; broker set brokerB } + val dave = Client.new { name = "Dave Brown"; email = "dave@example.com"; broker set brokerB } + flushCache() + + val aliceGrowth = Portfolio.new { name = "Growth Portfolio"; client set alice; createdAt = Clock.System.now() } + val aliceSafe = Portfolio.new { name = "Conservative Portfolio"; client set alice; createdAt = Clock.System.now() } + val bobMain = Portfolio.new { name = "Main Portfolio"; client set bob; createdAt = Clock.System.now() } + val carolTech = Portfolio.new { name = "Tech Portfolio"; client set carol; createdAt = Clock.System.now() } + flushCache() + + val now = Clock.System.now() + Trade.new { client set alice; instrument set aapl; portfolio set aliceGrowth; type = TradeType.BUY; quantity = 100; price = "178.50".toBigDecimal(); executedAt = now } + Trade.new { client set alice; instrument set tsla; portfolio set aliceGrowth; type = TradeType.BUY; quantity = 50; price = "242.00".toBigDecimal(); executedAt = now } + Trade.new { client set alice; instrument set bnd; portfolio set aliceSafe; type = TradeType.BUY; quantity = 200; price = "72.30".toBigDecimal(); executedAt = now } + Trade.new { client set bob; instrument set spy; portfolio set bobMain; type = TradeType.BUY; quantity = 150; price = "450.00".toBigDecimal(); executedAt = now } + Trade.new { client set carol; instrument set googl; portfolio set carolTech; type = TradeType.BUY; quantity = 30; price = "141.80".toBigDecimal(); executedAt = now } + Trade.new { client set dave; instrument set xom; portfolio set null; type = TradeType.BUY; quantity = 75; price = "105.20".toBigDecimal(); executedAt = now } + flushCache() + } + + call.respond(HttpStatusCode.Created, mapOf("status" to "Seed data created")) + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/TradeRoutes.kt b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/TradeRoutes.kt new file mode 100644 index 0000000000..81cf5866a2 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/TradeRoutes.kt @@ -0,0 +1,57 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.routes + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlin.time.Clock +import org.jetbrains.exposed.r2dbc.dao.flushCache +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +fun Application.tradeRoutes() { + routing { + route("/trades") { + post { + val dto = call.receive() + val result = suspendTransaction { + val client = Client.findById(dto.clientId) + ?: error("Client ${dto.clientId} not found") + val instrument = Instrument.findById(dto.instrumentId) + ?: error("Instrument ${dto.instrumentId} not found") + val portfolio = dto.portfolioId?.let { + Portfolio.findById(it) ?: error("Portfolio $it not found") + } + + val trade = Trade.new { + this.client set client + this.instrument set instrument + this.portfolio set portfolio + this.type = dto.type + this.quantity = dto.quantity + this.price = dto.price.toBigDecimal() + this.executedAt = Clock.System.now() + } + + flushCache() + + TradeDetailDTO( + id = trade.id.value, + instrumentTicker = trade.instrument().ticker, + instrumentName = trade.instrument().name, + type = trade.type, + quantity = trade.quantity, + price = trade.price.toPlainString(), + executedAt = trade.executedAt.toString(), + portfolioName = trade.portfolio()?.name + ) + } + call.respond(HttpStatusCode.Created, result) + } + } + } +} diff --git a/samples/exposed-dao-showcase/r2dbc/src/main/resources/application.yaml b/samples/exposed-dao-showcase/r2dbc/src/main/resources/application.yaml new file mode 100644 index 0000000000..bab9269e6a --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +ktor: + application: + modules: + - org.jetbrains.exposed.samples.broker.r2dbc.ApplicationKt.module + deployment: + port: 8081 diff --git a/settings.gradle.kts b/settings.gradle.kts index dd835f95c2..ecd7471336 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,10 @@ include("exposed-jdbc-r2dbc-tests") include("exposed-dao-r2dbc") include("exposed-dao-r2dbc-tests") include("exposed-gradle-plugin") +include("exposed-dao-showcase-jdbc") +project(":exposed-dao-showcase-jdbc").projectDir = file("samples/exposed-dao-showcase/jdbc") +include("exposed-dao-showcase-r2dbc") +project(":exposed-dao-showcase-r2dbc").projectDir = file("samples/exposed-dao-showcase/r2dbc") pluginManagement { repositories {