diff --git a/build.gradle.kts b/build.gradle.kts index 40b7c391cf..de89429ae0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,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 @@ -65,10 +66,12 @@ 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")) kover(project(":exposed-r2dbc-tests")) + kover(project(":exposed-dao-r2dbc-tests")) } repositories { @@ -80,6 +83,7 @@ allprojects { if (this.name != "exposed-tests" && this.name != "exposed-r2dbc-tests" && this.name != "exposed-jdbc-r2dbc-tests" && + this.name != "exposed-dao-r2dbc-tests" && this != rootProject ) { apply(plugin = "com.vanniktech.maven.publish") @@ -97,7 +101,7 @@ allprojects { apiValidation { ignoredProjects.addAll( - listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-version-catalog") + listOf("exposed-tests", "exposed-bom", "exposed-r2dbc-tests", "exposed-jdbc-r2dbc-tests", "exposed-version-catalog", "exposed-dao-r2dbc-tests") ) } diff --git a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/Publishing.kt b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/Publishing.kt index 0208755a93..81af337a93 100644 --- a/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/Publishing.kt +++ b/buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/Publishing.kt @@ -9,6 +9,15 @@ infix fun Property.by(value: T) { set(value) } +/** + * Whether this project publishes a Maven artifact. + * + * The root build applies the Maven Publish plugin to exactly the modules that are released, so its + * presence is the source of truth for publish state. Prefer this over a hand-maintained list of module + * names, which silently goes stale whenever a module is added. + */ +fun Project.publishesMavenArtifact(): Boolean = plugins.hasPlugin("maven-publish") + fun MavenPom.configureMavenCentralMetadata(project: Project) { name by project.name description by "Exposed, an ORM framework for Kotlin" diff --git a/documentation-website/Writerside/hi.tree b/documentation-website/Writerside/hi.tree index 71e5055372..5503fd2e93 100644 --- a/documentation-website/Writerside/hi.tree +++ b/documentation-website/Writerside/hi.tree @@ -64,6 +64,7 @@ + diff --git a/documentation-website/Writerside/topics/DAO-CRUD-Operations.topic b/documentation-website/Writerside/topics/DAO-CRUD-Operations.topic index 406119b4d2..787df45eaf 100644 --- a/documentation-website/Writerside/topics/DAO-CRUD-Operations.topic +++ b/documentation-website/Writerside/topics/DAO-CRUD-Operations.topic @@ -7,10 +7,11 @@

- Required dependencies: org.jetbrains.exposed:exposed-dao + Required dependencies: org.jetbrains.exposed:exposed-dao (JDBC), + org.jetbrains.exposed:exposed-dao-r2dbc (R2DBC)

- +

CRUD (Create, Read, Update, Delete) are the four basic operations supported by any database. This section @@ -342,5 +343,162 @@ DSL Statement Interceptors. + + + +

+ Run all DAO calls inside suspendTransaction, and make the enclosing functions + suspend: +

+ + + + + + + + + + +

+ new(), findById(), get(), findByIdAndUpdate(), + findSingleByAndUpdate(), count(), reload(), + Entity.delete(), Entity.flush(), and Entity.refresh() are all + suspending. all() and find { } are not, because they only build a query. +

+

+ Because new { } suspends, its initializer block does too, so you can call other DAO + operations inside it: +

+ + + + +

+ In exposed-r2dbc, SizedIterable extends + kotlinx.coroutines.flow.Flow. Collect the result before using operators that produce a + collection: +

+ + + + + + + + + + + + If kotlinx.coroutines.flow.map is in scope, the map call still compiles but + returns a Flow instead of a List. Add .toList() before any + operator that should produce a collection. + +
+ +

+ The JDBC DAO silently re-registers an entity on its first write in a new transaction. The R2DBC DAO + cannot, because that check is a database round trip and a property setter cannot suspend. Call + attach() first, or the write throws: +

+ + + + + + + + + + +

+ attach() throws EntityNotFoundException if the row no longer exists. +

+
+ +

+ new { } flushes immediately, costing one INSERT per entity. + newDeferred { } has no JDBC counterpart: it schedules the insert without flushing and + returns a cold Flow, so collecting several together produces a single batched + INSERT: +

+ = listOf("A New Hope", "The Empire Strikes Back") + .map { title -> StarWarsFilmEntity.newDeferred { name = title } } + .asFlow() + .flattenConcat() + .toList() + ]]> + +

+ Use flattenConcat rather than merge: merge does not preserve + the order in which the entities were scheduled. +

+ + Discarding the flow does not cancel the insert. It is flushed at the first of collection, any other + statement in the same transaction, or commit. Batching therefore only holds if nothing else touches + the database in between — an intervening new { } splits the batch. Collecting the flow + outside the transaction that created it throws. + +
+ +

+ EntityClass.view { }, findWithCacheCondition(), + testCache(predicate), and the Alias overloads of wrapRows() + have no R2DBC equivalent yet. +

+
+ diff --git a/documentation-website/Writerside/topics/DAO-Entity-definition.topic b/documentation-website/Writerside/topics/DAO-Entity-definition.topic index b57fcad894..7a3eec35b8 100644 --- a/documentation-website/Writerside/topics/DAO-Entity-definition.topic +++ b/documentation-website/Writerside/topics/DAO-Entity-definition.topic @@ -7,10 +7,11 @@

- Required dependencies: org.jetbrains.exposed:exposed-dao + Required dependencies: org.jetbrains.exposed:exposed-dao (JDBC), + org.jetbrains.exposed:exposed-dao-r2dbc (R2DBC)

- +

An Entity @@ -183,7 +184,62 @@ deleting records.

+ + +

+ Entity definitions are almost identical for both drivers. Table definitions come from + exposed-core and need no changes at all. +

+

+ Change the imports to the .r2dbc package: +

+ + + ) : IntEntity(id) { + companion object : IntEntityClass(StarWarsFilmsTable) + + var sequelId by StarWarsFilmsTable.sequelId + var name by StarWarsFilmsTable.name + var director by StarWarsFilmsTable.director + } + ]]> + + + + ) : IntEntity(id) { + companion object : IntEntityClass(StarWarsFilmsTable) + + var sequelId by StarWarsFilmsTable.sequelId + var name by StarWarsFilmsTable.name + var director by StarWarsFilmsTable.director + } + ]]> + + + +

+ Column properties are unchanged. Reference properties are not: they must become val. See + . +

+

+ Field transformations, including memoized ones, work the same way. + Immutable entities are the one feature on this page with no R2DBC + equivalent yet. +

+
+ + Available for JDBC only. Neither ImmutableEntityClass nor + ImmutableCachedEntityClass exists in exposed-dao-r2dbc. +

For defining entities that are immutable, Exposed provides the additional ImmutableEntityClass diff --git a/documentation-website/Writerside/topics/DAO-Relationships.topic b/documentation-website/Writerside/topics/DAO-Relationships.topic index 3e238db51c..540df1f1ba 100644 --- a/documentation-website/Writerside/topics/DAO-Relationships.topic +++ b/documentation-website/Writerside/topics/DAO-Relationships.topic @@ -7,10 +7,11 @@

- Required dependencies: org.jetbrains.exposed:exposed-dao + Required dependencies: org.jetbrains.exposed:exposed-dao (JDBC), + org.jetbrains.exposed:exposed-dao-r2dbc (R2DBC)

- +

Code example: exposed-dao-relationships @@ -417,4 +418,147 @@ + + +

+ All the relationship builders keep their names — referencedOn, + optionalReferencedOn, referrersOn, optionalReferrersOn, + backReferencedOn, optionalBackReferencedOn, and via — along with + their Column and IdTable overloads. How you read and write them changes. +

+ +

+ Reading a reference has to suspend, and a property getter cannot. So referencedOn and + optionalReferencedOn return an accessor instead of the entity: declare the property as + val, read it by invoking it, and write it with set(). +

+ + + ) : IntEntity(id) { + companion object : IntEntityClass(UserRatingsTable) + + var value by UserRatingsTable.value + var film by StarWarsFilmEntity referencedOn UserRatingsTable.film + } + + val film = rating.film + rating.film = otherFilm + ]]> + + + + ) : IntEntity(id) { + companion object : IntEntityClass(UserRatingsTable) + + var value by UserRatingsTable.value + val film by StarWarsFilmEntity referencedOn UserRatingsTable.film + } + + val film = rating.film() + rating.film.set(otherFilm) + ]]> + + + +

+ Leaving the property as var fails to compile with + Property delegate must have a 'setValue(...)' method. +

+ + Reading without the parentheses also compiles. val f = rating.film gives you the + accessor, not the film. Always write rating.film(). + +

+ Optional references work the same way, and the read returns + null when the column is null: +

+ ) : IntEntity(id) { + companion object : IntEntityClass(UserRatingsWithOptionalUserTable) + + val user by UserEntity optionalReferencedOn UserRatingsWithOptionalUserTable.user + } + + val user = rating.user() // UserEntity? + rating.user.set(null) + ]]> + +

+ Back references are also read by invoking: + user1.rating(). +

+
+ +

+ Referrer collections are SizedIterable, which extends + kotlinx.coroutines.flow.Flow in exposed-r2dbc. Collect before using + operators that produce a collection: +

+ + + + + + + + + + +

+ Ordered references are unchanged: orderBy still applies + to referrers and to via relations. +

+
+ +

+ via is the one relationship whose shape does not + change. The property stays a var, and assignment still takes a + SizedCollection. Only reading the collection needs .toList(): +

+ ) : IntEntity(id) { + companion object : IntEntityClass(StarWarsFilmsTable) + + var actors by ActorEntity via StarWarsFilmActorsTable + } + + film.actors = SizedCollection(listOf(actor1, actor2)) + val names = film.actors.toList().map { it.firstname } + ]]> + +
+ +

+ load() and with() keep their names but are + suspending, and move into the relationships subpackage. with() gains an + overload that accepts a SizedIterable, so it can be applied to a query result directly. +

+ + + + + + + + + + +
+ diff --git a/documentation-website/Writerside/topics/DAO-Table-Types.topic b/documentation-website/Writerside/topics/DAO-Table-Types.topic index 3d03439e58..36aeeb9488 100644 --- a/documentation-website/Writerside/topics/DAO-Table-Types.topic +++ b/documentation-website/Writerside/topics/DAO-Table-Types.topic @@ -5,6 +5,12 @@ xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd" title="Table types" id="DAO-Table-Types" help-id="DAO-Table-types"> + + Everything on this page applies to both the JDBC and the R2DBC DAO. Table types come from + exposed-core and are shared by both drivers, so table definitions need no changes when moving + between them. For the parts of the DAO that do differ, see + . +

diff --git a/documentation-website/Writerside/topics/Migration-Guide-DAO-JDBC-to-R2DBC.md b/documentation-website/Writerside/topics/Migration-Guide-DAO-JDBC-to-R2DBC.md new file mode 100644 index 0000000000..77ac78fa7b --- /dev/null +++ b/documentation-website/Writerside/topics/Migration-Guide-DAO-JDBC-to-R2DBC.md @@ -0,0 +1,220 @@ + + +# Migrating from the JDBC DAO to the R2DBC DAO + +How to move a DAO-based application from `exposed-dao` (JDBC only) to `exposed-dao-r2dbc`. + +Both artifacts ship in the same release, so this is not a version upgrade. If you are also coming from a `0.x` +release, apply [Migrating from 0.61.0 to 1.0.0](Migration-Guide-1-0-0.md) first. + + +The R2DBC DAO is an experimental preview: its API may change in incompatible ways between releases. + + +## Step 0. Check for blockers + +These JDBC DAO features have no R2DBC equivalent. If you use one, you cannot finish the migration: + +| Not available | Note | +|----------------------------------------|-------------------------------------------------| +| `ImmutableEntityClass` | — | +| `ImmutableCachedEntityClass` | — | +| `EntityClass.view { }` and `View` | use `find { }` instead | +| `EntityClass.findWithCacheCondition()` | — | +| `EntityClass.testCache(predicate)` | the `EntityID` overload does exist | +| `EntityClass.wrapRows(rows, alias)` | the `Alias`/`QueryAlias` overloads are missing | +| `Entity.lookupInReadValues()` | — | +| `warmUpReferences()` | the `forUpdate` parameter was dropped | + +## Step 1. Swap dependencies + + + +```kotlin +implementation("org.jetbrains.exposed:exposed-core:1.3.1") +implementation("org.jetbrains.exposed:exposed-jdbc:1.3.1") +implementation("org.jetbrains.exposed:exposed-dao:1.3.1") +implementation("com.h2database:h2:2.4.240") +``` + +```kotlin +implementation("org.jetbrains.exposed:exposed-core:1.3.1") +implementation("org.jetbrains.exposed:exposed-r2dbc:1.3.1") +implementation("org.jetbrains.exposed:exposed-dao-r2dbc:1.3.1") +implementation("io.r2dbc:r2dbc-h2:1.1.0.RELEASE") +``` + + + +Do not keep both DAO artifacts in one source set — they define `Entity`, `EntityClass`, and `EntityCache` with the +same simple names, so wildcard imports collide. + +## Step 2. Opt in + +Almost the whole API is annotated `@ExperimentalR2dbcDaoApi`, so opt in once per module: + +```kotlin +kotlin { + compilerOptions { + optIn.add("org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi") + } +} +``` + +## Step 3. Update imports + +Add `.r2dbc` to the DAO package. Relationship types and `load`/`with` also move into a `relationships` subpackage. + +| JDBC DAO | R2DBC DAO | +|-----------------------------------------------|-------------------------------------------------------------------| +| `org.jetbrains.exposed.v1.dao.IntEntity` | `org.jetbrains.exposed.v1.dao.r2dbc.IntEntity` | +| `org.jetbrains.exposed.v1.dao.IntEntityClass` | `org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass` | +| `org.jetbrains.exposed.v1.dao.entityCache` | `org.jetbrains.exposed.v1.dao.r2dbc.entityCache` | +| `org.jetbrains.exposed.v1.dao.load` | `org.jetbrains.exposed.v1.dao.r2dbc.relationships.load` | +| `org.jetbrains.exposed.v1.dao.with` | `org.jetbrains.exposed.v1.dao.r2dbc.relationships.with` | +| `org.jetbrains.exposed.v1.dao.Referrers` | `org.jetbrains.exposed.v1.dao.r2dbc.relationships.Referrers` | +| `org.jetbrains.exposed.v1.dao.InnerTableLink` | `org.jetbrains.exposed.v1.dao.r2dbc.relationships.InnerTableLink` | + +Table definitions need no changes: they come from `exposed-core` and are shared by both drivers. + +## Step 4. Replace `transaction` with `suspendTransaction` + +Every DAO call must run inside it, and the enclosing functions become `suspend`. + + + +```kotlin +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +val client = transaction { Client.findById(id) } +``` + +```kotlin +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction + +val client = suspendTransaction { Client.findById(id) } +``` + + + + +Do not use org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction. It suspends while holding a +blocking JDBC connection and is unrelated. + + +## Step 5. Change reference properties to `val` + +`referencedOn` and `optionalReferencedOn` now return an accessor, because reading a reference has to suspend and a +property getter cannot. So: declare `val`, read with `()`, write with `set()`. + + + +```kotlin +var broker by Broker referencedOn Clients.broker +var portfolio by Portfolio optionalReferencedOn Trades.portfolio + +val name = client.broker.name +client.broker = otherBroker +trade.portfolio = null +``` + +```kotlin +val broker by Broker referencedOn Clients.broker +val portfolio by Portfolio optionalReferencedOn Trades.portfolio + +val name = client.broker().name +client.broker.set(otherBroker) +trade.portfolio.set(null) +``` + + + +Leaving it as `var` fails to compile with `Property delegate must have a 'setValue(...)' method`. + + +Reading without the parentheses also compiles. val b = client.broker gives you the accessor, not the +Broker. Always write client.broker(). + + +`backReferencedOn` and `optionalBackReferencedOn` work the same way. `via` is unchanged — it stays a `var` and still +takes a `SizedCollection`. + +## Step 6. Collect collections + +`SizedIterable` now extends `Flow`, so referrers, `via` relations, `all()`, and `find { }` are flows. + + + +```kotlin +val names = client.portfolios.map { it.name } +``` + +```kotlin +val names = client.portfolios.toList().map { it.name } +``` + + + +`count()`, `first()`, `firstOrNull()`, and `single()` need no change. + + +If kotlinx.coroutines.flow.map is in scope, client.portfolios.map { } still compiles but +returns a Flow instead of a List. Add .toList() before any operator that +should produce a collection. + + +## Step 7. Add `attach()` across transactions + +The JDBC DAO silently re-registers an entity on first write in a new transaction. The R2DBC DAO cannot, because that +check is a database round trip and a property setter cannot suspend. Call `attach()` yourself, or the write throws. + + + +```kotlin +val item = transaction { Item.new { name = "foo" } } + +transaction { + item.name = "bar" +} +``` + +```kotlin +val item = suspendTransaction { Item.new { name = "foo" } } + +suspendTransaction { + Item.attach(item) + item.name = "bar" +} +``` + + + +`attach()` throws `EntityNotFoundException` if the row is gone. + +## Optional: batch inserts with `newDeferred` + +`new { }` costs one `INSERT` per entity. `newDeferred { }` schedules without flushing and returns a cold `Flow`, so +collecting several together produces one batched `INSERT`: + +```kotlin +val tags: List = listOf("tech", "finance", "energy") + .map { name -> Tag.newDeferred { this.name = name } } + .asFlow() + .flattenConcat() + .toList() +``` + +Use `flattenConcat`, not `merge` — `merge` does not preserve order. + + +Discarding the flow does not cancel the insert; it is flushed at the first of collection, any other statement +in the same transaction, or commit. So batching only holds if nothing else touches the database in between, and an +intervening new { } splits the batch. Collecting the flow outside its own transaction throws. + + +## Samples + +The same application written against each DAO, which is the quickest way to compare: + +* [`samples/exposed-dao-showcase/jdbc`](https://github.com/JetBrains/Exposed/tree/main/samples/exposed-dao-showcase/jdbc) +* [`samples/exposed-dao-showcase/r2dbc`](https://github.com/JetBrains/Exposed/tree/main/samples/exposed-dao-showcase/r2dbc) diff --git a/documentation-website/Writerside/topics/lib.topic b/documentation-website/Writerside/topics/lib.topic index fec81caf93..4beb972b87 100644 --- a/documentation-website/Writerside/topics/lib.topic +++ b/documentation-website/Writerside/topics/lib.topic @@ -69,6 +69,26 @@

+ + +

+ The R2DBC DAO (exposed-dao-r2dbc) is an experimental preview: its API may change in + incompatible ways between releases. Every declaration requires opting in to + @ExperimentalR2dbcDaoApi, most easily module-wide: +

+ + kotlin { + compilerOptions { + optIn.add("org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi") + } + } + +

+ For the complete list of differences, see . +

+ + + diff --git a/exposed-bom/build.gradle.kts b/exposed-bom/build.gradle.kts index 23018665d8..6881469eb0 100644 --- a/exposed-bom/build.gradle.kts +++ b/exposed-bom/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.exposed.gradle.publishesMavenArtifact + plugins { `java-platform` alias(libs.plugins.maven.publish) @@ -10,7 +12,7 @@ javaPlatform.allowDependencies() dependencies { constraints { rootProject.subprojects.forEach { - if (it.plugins.hasPlugin("maven-publish") && it.name != name && it.name != "exposed-version-catalog") { + if (it.publishesMavenArtifact() && it.name != name && it.name != "exposed-version-catalog") { it.publishing.publications.all { if (this is MavenPublication) { if (!artifactId.endsWith("-metadata") && 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-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 new file mode 100644 index 0000000000..78350b62a2 --- /dev/null +++ b/exposed-dao-r2dbc-tests/build.gradle.kts @@ -0,0 +1,93 @@ +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 + alias(libs.plugins.serialization) +} + +kotlin { + jvmToolchain(17) + + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("kotlin.uuid.ExperimentalUuidApi") + optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn.add("kotlinx.coroutines.DelicateCoroutinesApi") + optIn.add("org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi") + } +} + +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")) + testImplementation(project(":exposed-java-time")) + testImplementation(project(":exposed-kotlin-datetime")) + 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) { + 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 { + if (name == "compileTestKotlin") { + jvmTarget.set(JvmTarget.JVM_17) + } else { + jvmTarget.set(JvmTarget.JVM_11) + } + } +} + +tasks.withType().configureEach { + targetCompatibility = if (name == "compileTestJava") "17" else "11" +} + +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/v1/dao/r2dbc/tests/crypt/EncryptedColumnDaoTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/crypt/EncryptedColumnDaoTests.kt new file mode 100644 index 0000000000..ec7b7482b1 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/crypt/EncryptedColumnDaoTests.kt @@ -0,0 +1,89 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.crypt + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.singleOrNull +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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 EncryptedColumnDaoTests : 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) : IntEntity(id) { + companion object : IntEntityClass(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.contentToString(), entity.binary.contentToString()) + + 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/v1/dao/r2dbc/tests/demo/dao/SamplesDao.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/demo/dao/SamplesDao.kt new file mode 100644 index 0000000000..8daeaa35c2 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/demo/dao/SamplesDao.kt @@ -0,0 +1,89 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.demo.dao + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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) : IntEntity(id) { + companion object : IntEntityClass(Users) + + var name by Users.name + val city by City referencedOn Users.city + var age by Users.age +} + +class City(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Cities) + + var name by Cities.name + val users by User referrersOn 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/v1/dao/r2dbc/tests/h2/EntityReferenceCacheTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/h2/EntityReferenceCacheTest.kt new file mode 100644 index 0000000000..94fadda789 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/h2/EntityReferenceCacheTest.kt @@ -0,0 +1,578 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.load +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +import org.jetbrains.exposed.v1.dao.r2dbc.tests.demo.dao.Cities +import org.jetbrains.exposed.v1.dao.r2dbc.tests.demo.dao.City +import org.jetbrains.exposed.v1.dao.r2dbc.tests.demo.dao.User +import org.jetbrains.exposed.v1.dao.r2dbc.tests.demo.dao.Users +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.EntityTests +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.EntityTestsData +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.VNumber +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.VString +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.ViaTestData +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +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.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class EntityReferenceCacheTest : 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: EntityTests.Board by Delegates.notNull() + var p1: EntityTests.Post by Delegates.notNull() + var p2: EntityTests.Post by Delegates.notNull() + executeOnH2(EntityTests.Boards, EntityTests.Posts, EntityTests.Categories) { + suspendTransaction(db) { + b1 = EntityTests.Board.new { + name = "test-board" + } + p1 = EntityTests.Post.new { + board.set(b1) + } + p2 = EntityTests.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(EntityTests.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: EntityTests.Board by Delegates.notNull() + var p1: EntityTests.Post by Delegates.notNull() + var p2: EntityTests.Post by Delegates.notNull() + executeOnH2(EntityTests.Boards, EntityTests.Posts, EntityTests.Categories) { + suspendTransaction(db) { + b1 = EntityTests.Board.new { + name = "test-board" + } + p1 = EntityTests.Post.new { + board.set(b1) + } + p2 = EntityTests.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: EntityTests.Board by Delegates.notNull() + var p1: EntityTests.Post by Delegates.notNull() + var p2: EntityTests.Post by Delegates.notNull() + executeOnH2(EntityTests.Boards, EntityTests.Posts, EntityTests.Categories) { + suspendTransaction(db) { + b1 = EntityTests.Board.new { + name = "test-board" + } + p1 = EntityTests.Post.new { + board.set(b1) + } + p2 = EntityTests.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(EntityTests.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 = SizedCollection(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 = SizedCollection(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 = SizedCollection(listOf(s1)) + assertEqualCollections(n.connectedStrings.map { it.id }.toList(), s1.id) + n.load(VNumber::connectedStrings) + assertEqualCollections(n.connectedStrings.map { it.id }.toList(), s1.id) + } + } + } + + /** + * The reference cache is only populated when `keepLoadedReferencesOutOfTransaction` is enabled, so + * handing back the raw cache entry here would yield `null` typed as a non-null `SizedIterable` and + * surface later as an opaque NPE. Hence the explicit error. + */ + @Test + fun `test via reference out of transaction without cache reports a usage error`() = runTest { + var n: VNumber by Delegates.notNull() + executeOnH2(*ViaTestData.allTables) { + suspendTransaction(db) { + n = VNumber.new { number = 10 } + val s1 = VString.new { text = "aaa" } + n.connectedStrings = SizedCollection(listOf(s1)) + } + + val failure = assertFailsWith { + n.connectedStrings.toList() + } + val message = assertNotNull(failure.message) + assertTrue( + message.contains("not in the entity cache") && + message.contains("keepLoadedReferencesOutOfTransaction"), + "expected a message naming the cause and the remedy, got: $message" + ) + } + } + + 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) : IntEntity(id) { + var name by Customers.name + val orders by Order.referrersOn(Orders.customer) + val addresses by Address.referrersOn(Addresses.customer) + val customerRoles by CustomerRole.referrersOn(CustomerRoles.customer) + + companion object : IntEntityClass(Customers) + } + + class Order(id: EntityID) : IntEntity(id) { + var ref by Orders.ref + val customer by Customer.referencedOn(Orders.customer) + val items by OrderItem.referrersOn(OrderItems.order) + + companion object : IntEntityClass(Orders) + } + + class OrderItem(id: EntityID) : IntEntity(id) { + var sku by OrderItems.sku + val order by Order.referencedOn(OrderItems.order) + + companion object : IntEntityClass(OrderItems) + } + + class Address(id: EntityID) : IntEntity(id) { + var street by Addresses.street + val customer by Customer.referencedOn(Addresses.customer) + + companion object : IntEntityClass
(Addresses) + } + + class Role(id: EntityID) : IntEntity(id) { + var name by Roles.name + + companion object : IntEntityClass(Roles) + } + + class CustomerRole(id: EntityID) : IntEntity(id) { + val customer by Customer.referencedOn(CustomerRoles.customer) + val role by Role.referencedOn(CustomerRoles.role) + + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/h2/MultiDatabaseEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/h2/MultiDatabaseEntityTest.kt new file mode 100644 index 0000000000..def72818f6 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/h2/MultiDatabaseEntityTest.kt @@ -0,0 +1,229 @@ +package org.jetbrains.exposed.v1.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.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.EntityTestsData +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 MultiDatabaseEntityTest : 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/v1/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt new file mode 100644 index 0000000000..67632b3ff2 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/javatime/R2dbcJavatimeDefaultsTest.kt @@ -0,0 +1,288 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.javatime + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +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.IdTable +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.core.eq +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +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 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 JavatimeDefaultsTest : 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) : IntEntity(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 : IntEntityClass(TableWithDBDefault) + } + + object DefaultTimestampTable : IntIdTable("test_table") { + val timestamp: Column = + timestampWithTimeZone("timestamp").defaultExpression(dbTimestampNow) + } + + class DefaultTimestampEntity(id: EntityID) : Entity(id) { + companion object : EntityClass(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" } + ) + // 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" } + 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) : Entity(id) { + var value by TableWithDefaultValue.value + var valueWithDefault by TableWithDefaultValue.valueWithDefault + + companion object : EntityClass(TableWithDefaultValue) + } + + @Test + fun testExplicitInsertionOfDefaultValuesWithIdTable() { + withTables(TableWithDefaultValue) { + val entity = TableWithDefaultValueEntity.new(5) { + value = 94 + valueWithDefault = TableWithDefaultValue.DEFAULT_VALUE + } + // R2DBC's `new { }` eagerly flushes, so we verify the persisted row rather than + // pre-flush `writeValues` (which is cleared after the insert completes). + assertEquals(TableWithDefaultValue.DEFAULT_VALUE, entity.valueWithDefault) + val row = TableWithDefaultValue.selectAll() + .where { TableWithDefaultValue.id eq entity.id }.first() + assertEquals(TableWithDefaultValue.DEFAULT_VALUE, row[TableWithDefaultValue.valueWithDefault]) + } + } +} + +/** + * 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/v1/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt new file mode 100644 index 0000000000..d45ffd7bf6 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/jodatime/R2dbcJodaTimeDefaultTests.kt @@ -0,0 +1,152 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.jodatime + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 JodaTimeDefaultTests : 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) : IntEntity(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 : IntEntityClass(TableWithDBDefault) + } + + @Test + fun testDefaultsWithExplicit01() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = DateTime.now().minusDays(5) + } + ) + 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" } + ) + + // 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" } + 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) : Entity(id) { + companion object : EntityClass(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/v1/dao/r2dbc/tests/json/JsonColumnTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/JsonColumnTests.kt new file mode 100644 index 0000000000..78311f0ca5 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/JsonColumnTests.kt @@ -0,0 +1,49 @@ +package org.jetbrains.exposed.v1.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 JsonColumnTests : 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/v1/dao/r2dbc/tests/json/JsonTestsData.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/JsonTestsData.kt new file mode 100644 index 0000000000..1e4d104c67 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/JsonTestsData.kt @@ -0,0 +1,38 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.json + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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) : IntEntity(id) { + companion object : IntEntityClass(JsonTable) + + var jsonColumn by JsonTable.jsonColumn + } + + class JsonBEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt new file mode 100644 index 0000000000..6cba3edd48 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/json/R2bdcJsonBColumnTests.kt @@ -0,0 +1,84 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.json + +import kotlinx.coroutines.flow.single +import kotlinx.serialization.json.Json +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.json.extract +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) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/kotlindatetime/KotlinDatetimeDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/kotlindatetime/KotlinDatetimeDefaultsTest.kt new file mode 100644 index 0000000000..68e31accf8 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/kotlindatetime/KotlinDatetimeDefaultsTest.kt @@ -0,0 +1,142 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 KotlinDatetimeDefaultsTest : 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) : IntEntity(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 : IntEntityClass(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" } + ) + + // 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" } + 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) : Entity(id) { + companion object : EntityClass(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/v1/dao/r2dbc/tests/money/MoneyDefaultsTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/money/MoneyDefaultsTest.kt new file mode 100644 index 0000000000..f935d5313b --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/money/MoneyDefaultsTest.kt @@ -0,0 +1,95 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.money + +import kotlinx.coroutines.flow.toList +import org.javamoney.moneta.Money +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 MoneyDefaultsTest : 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) : IntEntity(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 : IntEntityClass(TableWithDBDefault) + } + + @Test + fun testDefaultsWithExplicit() { + withTables(TableWithDBDefault) { + val created = listOf( + DBDefault.new { field = "1" }, + DBDefault.new { + field = "2" + t1 = Money.of(BigDecimal.TEN, "USD") + } + ) + 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" } + 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" } + 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/v1/dao/r2dbc/tests/money/R2dbcMoneyTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/money/R2dbcMoneyTests.kt new file mode 100644 index 0000000000..4f372001e1 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/money/R2dbcMoneyTests.kt @@ -0,0 +1,65 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.money + +import kotlinx.coroutines.flow.toList +import org.javamoney.moneta.Money +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.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +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 MoneyTests : 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) : IntEntity(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 : EntityClass(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/v1/dao/r2dbc/tests/shared/AliasesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/AliasesTests.kt new file mode 100644 index 0000000000..466a95e6b4 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/AliasesTests.kt @@ -0,0 +1,47 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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 + } + + 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 + } + + 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/v1/dao/r2dbc/tests/shared/AttachEntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/AttachEntityTests.kt new file mode 100644 index 0000000000..13d020fb78 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/AttachEntityTests.kt @@ -0,0 +1,328 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.exceptions.EntityNotFoundException +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.expectException +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AttachEntityTests : R2dbcDatabaseTestsBase() { + + object Items : IntIdTable("attach_test_items") { + val name = varchar("name", 255) + } + + class Item(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Items) + + var name by Items.name + } + + object Owners : IntIdTable("attach_test_owners") { + val name = varchar("name", 255) + } + + class Owner(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Owners) + + var name by Owners.name + } + + object OwnedItems : IntIdTable("attach_test_owned_items") { + val owner = reference("owner", Owners) + val optionalOwner = optReference("optional_owner", Owners) + } + + class OwnedItem(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(OwnedItems) + + val owner by Owner referencedOn OwnedItems.owner + val optionalOwner by Owner optionalReferencedOn OwnedItems.optionalOwner + } + + private suspend fun newTransaction(statement: suspend R2dbcTransaction.() -> T) = + inTopLevelSuspendTransaction(null, statement = statement) + + @Test + fun testAttachAndModifyInNewTransaction() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "foo" } + } + newTransaction { + maxAttempts = 1 + assertNull(Item.testCache(item.id)) + assertEquals("foo", Items.selectAll().single()[Items.name]) + Item.attach(item) + item.name = "bar" + assertEquals(item, Item.testCache(item.id)) + assertEquals("bar", Items.selectAll().single()[Items.name]) + } + + newTransaction { + maxAttempts = 1 + assertEquals("bar", Items.selectAll().single()[Items.name]) + } + } + } + + @Test + fun testAttachAndModifyIsAutoFlushedOnCommit() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + Item.attach(item) + item.name = "modified" + // No explicit flush — auto-flushed by beforeCommit via flushCache() + } + + newTransaction { + maxAttempts = 1 + assertEquals("modified", Items.selectAll().single()[Items.name]) + } + } + } + + @Test + fun testAttachPreservesModificationsAcrossTransactionHops() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + Item.attach(item) + item.name = "from_txA" + } + + newTransaction { + maxAttempts = 1 + Item.attach(item) + assertEquals("from_txA", item.name) + } + } + } + + @Test + fun testModifyEntityWithoutAttachThrows() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + expectException { + item.name = "boom" + } + } + } + } + + /** + * A reference write must reject a detached entity the same way a plain column write does + * (see [testModifyEntityWithoutAttachThrows]). Reference writes assign `writeValues` directly + * instead of going through `Entity.setValue`, so without an explicit guard the assignment is + * dropped silently and the caller believes it succeeded. + */ + @Test + fun testSetReferenceWithoutAttachThrows() { + withTables(Owners, OwnedItems) { + val (item, firstOwnerId, secondOwner) = newTransaction { + maxAttempts = 1 + val first = Owner.new { name = "first" } + val second = Owner.new { name = "second" } + val owned = OwnedItem.new { owner.set(first) } + Triple(owned, first.id, second) + } + + newTransaction { + maxAttempts = 1 + expectException { + item.owner.set(secondOwner) + } + expectException { + item.optionalOwner.set(secondOwner) + } + expectException { + item.optionalOwner.set(null) + } + } + + newTransaction { + maxAttempts = 1 + val row = OwnedItems.selectAll().single() + assertEquals(firstOwnerId, row[OwnedItems.owner]) + assertNull(row[OwnedItems.optionalOwner]) + } + } + } + + @Test + fun testSetReferenceAfterAttachSucceeds() { + withTables(Owners, OwnedItems) { + val (item, _, secondOwner) = newTransaction { + maxAttempts = 1 + val first = Owner.new { name = "first" } + val second = Owner.new { name = "second" } + val owned = OwnedItem.new { owner.set(first) } + Triple(owned, first.id, second) + } + + newTransaction { + maxAttempts = 1 + OwnedItem.attach(item) + Owner.attach(secondOwner) + item.owner.set(secondOwner) + } + + newTransaction { + maxAttempts = 1 + assertEquals(secondOwner.id, OwnedItems.selectAll().single()[OwnedItems.owner]) + } + } + } + + @Test + fun testModifyDeletedEntityThrowsNotFound() { + withTables(Items) { + newTransaction { + maxAttempts = 1 + expectException { + val item = Item.new { name = "doomed" } + item.delete() + item.name = "boom" + } + } + } + } + + @Test + fun testAttachDeletedEntityThrows() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "doomed" } + } + + newTransaction { + maxAttempts = 1 + Item.attach(item) + item.delete() + } + + newTransaction { + maxAttempts = 1 + expectException { + Item.attach(item) + } + } + } + } + + @Test + fun testAttachIsIdempotentWithinSameTransaction() { + withTables(Items) { + val item = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + Item.attach(item) + item.name = "changed" + Item.attach(item) + assertEquals("changed", item.name) + } + } + } + + @Test + fun testAttachReplacesACleanTrackedInstance() { + withTables(Items) { + val carriedOver = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + val tracked = assertNotNull(Item.findById(carriedOver.id)) + assertTrue(carriedOver !== tracked, "expected two instances of the same row") + + Item.attach(carriedOver) + carriedOver.name = "changed" + } + + newTransaction { + maxAttempts = 1 + assertEquals("changed", Items.selectAll().single()[Items.name]) + } + } + } + + @Test + fun testAttachRejectsReplacingATrackedInstanceWithUnflushedChanges() { + withTables(Items) { + val carriedOver = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + val tracked = assertNotNull(Item.findById(carriedOver.id)) + tracked.name = "pending" + + expectException { + Item.attach(carriedOver) + } + } + } + } + + @Test + fun testAttachForceReplacesATrackedInstanceWithUnflushedChanges() { + withTables(Items) { + val carriedOver = newTransaction { + maxAttempts = 1 + Item.new { name = "original" } + } + + newTransaction { + maxAttempts = 1 + val tracked = assertNotNull(Item.findById(carriedOver.id)) + tracked.name = "discarded" + + Item.attach(carriedOver, force = true) + carriedOver.name = "kept" + } + + newTransaction { + maxAttempts = 1 + assertEquals("kept", Items.selectAll().single()[Items.name]) + } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/CompositeIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/CompositeIdTableEntityTest.kt new file mode 100644 index 0000000000..5ebf811f32 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/CompositeIdTableEntityTest.kt @@ -0,0 +1,829 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.CompositeEntity +import org.jetbrains.exposed.v1.dao.r2dbc.CompositeEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.load +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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.currentTestDB +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 CompositeIdTableEntityTest : 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(Publishers) + + var name by Publishers.name + val authors by Author referrersOn Authors + val office by Office optionalBackReferencedOn Offices + val allOffices by Office optionalReferrersOn 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) : IntEntity(id) { + companion object : IntEntityClass(Authors) + + val publisher by Publisher referencedOn 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(Books) + + var title by Books.title + val author by Author optionalReferencedOn Books.author + val review by Review backReferencedOn 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(Reviews) + + // R2DBC: relationship is mutated via `book.set(bookValue)` on the accessor. + val book by Book referencedOn 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(Offices) + + var staff by Offices.staff + val publisher by Publisher optionalReferencedOn 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" + } + val authorA = Author.new { + publisher.set(publisherA) + penName = "Author A" + } + val bookA = Book.new { + title = "Book A" + author.set(authorA) + } + 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 + 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 + + 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(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" + } + val authorA = Author.new { + publisher.set(publisherA) + penName = "Author A" + } + val authorB = Author.new { + publisher.set(publisherA) + penName = "Author B" + } + 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) + } + + // 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" + } + 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" + } + 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" + } + 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" + } + 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 Entity<*>.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/v1/dao/r2dbc/tests/shared/DDLTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/DDLTests.kt new file mode 100644 index 0000000000..f58b76e16e --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/DDLTests.kt @@ -0,0 +1,28 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import kotlin.test.Test + +class DDLTests : R2dbcDatabaseTestsBase() { + + object KeyWordTable : IntIdTable(name = "keywords") { + val bool = bool("bool") + } + + @Test + fun testDropTableFlushesCache() { + class Keyword(id: EntityID) : IntEntity(id) { + var bool by KeyWordTable.bool + } + + val keywordEntityClass = object : IntEntityClass(KeyWordTable, Keyword::class.java) {} + + withTables(KeyWordTable) { + keywordEntityClass.new { bool = true } + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityCacheRefreshTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityCacheRefreshTests.kt new file mode 100644 index 0000000000..da457f026f --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityCacheRefreshTests.kt @@ -0,0 +1,203 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 EntityCacheRefreshTests : R2dbcDatabaseTestsBase() { + // SQL Server has no `SELECT ... FOR UPDATE`; it uses locking hints instead, so the + // row-locking tests below cannot express the same contention there. + val excludedDbs = listOf(TestDB.SQLSERVER) + + object TestTable : IntIdTable("entity_cache_refresh_test") { + val value = integer("value") + } + + class TestEntity(id: EntityID) : IntEntity(id) { + var value by TestTable.value + + companion object : IntEntityClass(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) : IntEntity(id) { + var value by ExtendedTestTable.value + var name by ExtendedTestTable.name + + companion object : IntEntityClass(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.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" + ) + } + } + } + + /** + * `wrapRow()` on a hand-written partial SELECT merges selectively: columns present in the query + * are refreshed, columns absent from it keep their previously cached values. + */ + @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/v1/dao/r2dbc/tests/shared/EntityCacheTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityCacheTests.kt new file mode 100644 index 0000000000..c4aa5b3da2 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityCacheTests.kt @@ -0,0 +1,339 @@ +package org.jetbrains.exposed.v1.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.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +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.eq +import org.jetbrains.exposed.v1.core.exposedLogger +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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.random.Random + +class EntityCacheTests : R2dbcDatabaseTestsBase() { + object TestTable : IntIdTable("TestCache") { + val value = integer("value") + } + + class TestEntity(id: EntityID) : IntEntity(id) { + var value by TestTable.value + + companion object : IntEntityClass(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) : IntEntity(id) { + var value by TableWithDefaultValue.value + + var valueWithDefault by TableWithDefaultValue.valueWithDefault + + companion object : IntEntityClass(TableWithDefaultValue) + } + + @Test + fun entitiesWithDifferentAmountOfFieldsCouldBeCreated() { + withTables(TableWithDefaultValue) { + TableWithDefaultValueEntity.new { + value = 1 + } + TableWithDefaultValueEntity.new { + value = 2 + valueWithDefault = 1 + } + + 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 + + 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/v1/dao/r2dbc/tests/shared/EntityFieldWithTransformTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityFieldWithTransformTest.kt new file mode 100644 index 0000000000..d39fcc8a56 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityFieldWithTransformTest.kt @@ -0,0 +1,172 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 EntityFieldWithTransformTest : R2dbcDatabaseTestsBase() { + object TransformationsTable : IntIdTable() { + val value = varchar("value", 50) + } + + object NullableTransformationsTable : IntIdTable() { + val value = varchar("nullable", 50).nullable() + } + + class TransformationEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(TransformationsTable) + + var value by TransformationsTable.value.transform( + unwrap = { "transformed-$it" }, + wrap = { it.replace("transformed-", "") } + ) + } + + class NullableTransformationEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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) : IntEntity(id) { + companion object : IntEntityClass(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) : IntEntity(id) { + companion object : IntEntityClass(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) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/shared/EntityHookTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityHookTest.kt new file mode 100644 index 0000000000..da080653bf --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityHookTest.kt @@ -0,0 +1,371 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.single +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.dao.r2dbc.EntityChange +import org.jetbrains.exposed.v1.dao.r2dbc.EntityChangeType +import org.jetbrains.exposed.v1.dao.r2dbc.EntityHook +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.registeredChanges +import org.jetbrains.exposed.v1.dao.r2dbc.toEntity +import org.jetbrains.exposed.v1.dao.r2dbc.withHook +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +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) : IntEntity(id) { + companion object : IntEntityClass(Users) + + var name by Users.name + var age by Users.age + var cities by City via UsersToCities + } + + class City(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Cities) + + var name by Cities.name + var users by User via UsersToCities + val country by Country referencedOn Cities.country + } + + class Country(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Countries) + + var name by Countries.name + val cities by City referrersOn Cities.country + } + + val allTables = arrayOf(Users, Cities, UsersToCities, Countries) +} + +class EntityHookTest : 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) + } + + 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 + } + } + + val (_, events, txId) = trackChanges { + val spb = EntityHookTestData.City.find { EntityHookTestData.Cities.name eq "St. Petersburg" }.single() + val john = EntityHookTestData.User.all().single() + john.cities = SizedCollection(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 = SizedCollection(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 = SizedCollection(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 = SizedCollection(listOf(spb)) + flushCache() + } + + val (_, events, txId) = trackChanges { + val john = EntityHookTestData.User.all().single() + john.cities = SizedCollection(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 + } + } + + 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/v1/dao/r2dbc/tests/shared/EntityTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityTests.kt new file mode 100644 index 0000000000..e99630e329 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityTests.kt @@ -0,0 +1,1796 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntity +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.exceptions.EntityNotFoundException +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.load +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +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 { + + 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) { + suspend 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 optionalReferencedOn XTable.y1 + + companion object : IntEntityClass(XTable) { + suspend 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 by BEntity backReferencedOn XTable.y1 + val bOpt by BEntity optionalBackReferencedOn XTable.y1 + + companion object : EntityClass(YTable) + } +} + +@Suppress("LargeClass") +class EntityTests : 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" + } + + 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) : IntEntity(id) { + companion object : IntEntityClass(OneAutoFieldTable) + } + + @Test + fun testOneFieldEntity() { + withTables(OneAutoFieldTable) { + val new = SingleFieldEntity.new { } + commit() + } + } + + @Test + fun testBackReference01() { + withTables(EntityTestsData.YTable, EntityTestsData.XTable) { + val y = EntityTestsData.YEntity.new { } + 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 { } + val y = EntityTestsData.YEntity.new { } + b.y.set(y) + assertEquals(b, y.b()) + } + } + + object Items : IntIdTable("items") { + val name = varchar("name", 255).uniqueIndex() + val price = double("price") + } + + class Item(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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 + 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) : IntEntity(id) { + var parent by SelfReferenceTable.parentId + + companion object : IntEntityClass(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) + } + + 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) : IntEntity(id) { + companion object : IntEntityClass(Boards) + + var name by Boards.name + val posts by Post optionalReferrersOn Posts.board + } + + class Post(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(Posts) + + val board by Board optionalReferencedOn Posts.board + val parent by Post optionalReferencedOn Posts.parent + val category by Category optionalReferencedOn Posts.category + val optCategory by Category optionalReferencedOn Posts.optCategory + } + + class Category(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Categories) + + val uniqueId by Categories.uniqueId + var title by Categories.title + val posts by Post optionalReferrersOn 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()) + } + } + + @Test + fun testInsertNonChildWithoutFlush() { + withTables(Boards, Posts, Categories) { + val board = Board.new { name = "irrelevant" } + Post.new { this.board.set(board) } + assertEquals(0, 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" }) } + assertNotNull(parent.id._value) + Post.new { this.parent.set(parent) } + assertEquals(0, 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) } + } + } + + @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" }) + } + assertEquals(1, board.posts.count()) + assertEquals(post1, board.posts.single()) + + Post.new { this.board.set(board) } + 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)) + 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)) + 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) + } + + object Users : IdTable("user") { + override val id: Column> = reference("id", Humans) + val name = text("name") + } + + open class Human(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Humans) + + var h by Humans.h + } + + class User(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Users) { + suspend 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 referencedOn 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) + } + + 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) : LongEntity(id) { + companion object : LongEntityClass(Parents) + + var name by Parents.name + } + + object Children : LongIdTable() { + val companyId = reference("company_id", Parents) + val name = varchar("name", 80) + } + + class Child(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(Children) + + val parent by Parent referencedOn 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" + }.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" } + // Unlike JDBC, where this asserts `true`: R2DBC's `new` is suspending and already + // flushed the insert, so there is nothing left to send and `flush()` reports `false`. + assertEquals(false, board.flush()) + + assertEquals("Board1", board.name) + } + } + + 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) : IntEntity(id) { + companion object : IntEntityClass(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) : LongEntity(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 : LongEntityClass(Students) + + var name by Students.name + val school by School referencedOn Students.school + val notes by Note.referrersOn(Notes.student, true) + val detentions by Detention.optionalReferrersOn(Detentions.student, true) + val bio by StudentBio.optionalBackReferencedOn(StudentBios.student) + } + + class StudentBio(id: EntityID) : ComparableLongEntity(id) { + companion object : LongEntityClass(StudentBios) + + val student by Student.referencedOn(StudentBios.student) + var dateOfBirth by StudentBios.dateOfBirth + } + + class Note(id: EntityID) : ComparableLongEntity(id) { + companion object : LongEntityClass(Notes) + + var text by Notes.text + val student by Student referencedOn Notes.student + } + + class Detention(id: EntityID) : ComparableLongEntity(id) { + companion object : LongEntityClass(Detentions) + + var reason by Detentions.reason + val student by Student optionalReferencedOn Detentions.student + } + + class Holiday(id: EntityID) : ComparableLongEntity(id) { + companion object : LongEntityClass(Holidays) + + var holidayStart by Holidays.holidayStart + var holidayEnd by Holidays.holidayEnd + } + + class School(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Schools) + + var name by Schools.name + val region by Region referencedOn Schools.region + val secondaryRegion by Region optionalReferencedOn Schools.secondaryRegion + val students by Student.referrersOn(Students.school, true) + var holidays by Holiday via 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 = SizedCollection(listOf(holiday1, holiday2)) + school2.holidays = SizedCollection(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" + } + ) + + 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 : IntEntityClass(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) : Entity(id) { + companion object : EntityClass(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) : IntEntity(id) { + companion object : IntEntityClass(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" + } + + assertEquals(10000uL, creditCard.spendingLimit) + } + } + + @Test + fun testEntityIdParam() { + withTables(CreditCards) { + val newCard = CreditCard.new { + number = "0000111122223333" + spendingLimit = 10000uL + } + + 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) : Entity(id) { + var name by Countries.name + val dishes by Dish referencedOn Dishes.country + + companion object : EntityClass(Countries) + } + + object Dishes : IntIdTable("Dishes") { + var name = text("name") + val country = reference("country_id", Countries) + } + + class Dish(id: EntityID) : IntEntity(id) { + var name by Dishes.name + val country by Country referencedOn Dishes.country + + companion object : IntEntityClass(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) : IntEntity(id) { + var emailAddress by Customers.emailAddress + var name by Customers.fullName + + val orders by Order referrersOn Orders.customer + + companion object : IntEntityClass(Customers) + } + + object Orders : IntIdTable("Orders") { + var orderName = text("orderName") + val customer = reference("customer", Customers.emailAddress) + } + + class Order(id: EntityID) : IntEntity(id) { + var name by Orders.orderName + val customer by Customer referencedOn Orders.customer + + companion object : IntEntityClass(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) : IntEntity(id) { + var value by TestTable.value + + companion object : IntEntityClass(TestTable) + } + + class TestEntityB(id: EntityID) : IntEntity(id) { + var value by TestTable.value + + companion object : IntEntityClass(TestTable) + } + + @Test + fun testDifferentEntitiesMappedToTheSameTable() { + withTables(TestTable) { + val entityA = TestEntityA.new { + value = 1 + } + val entityB = TestEntityB.new { + value = 2 + } + + entityA.value = 3 + entityB.value = 4 + + flushCache() + } + } + + @Test + fun testForIds() { + withTables(Humans) { + val h1 = Human.new { h = "h1" } + val h2 = Human.new { h = "h2" } + Human.new { h = "h3" } + + val byIds = Human.forIds(listOf(h1.id.value, h2.id.value)).toList() + assertEquals(setOf("h1", "h2"), byIds.map { it.h }.toSet()) + } + } +} + +/** + * 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-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityWithBlobTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityWithBlobTests.kt new file mode 100644 index 0000000000..5706159f23 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/EntityWithBlobTests.kt @@ -0,0 +1,53 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +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 EntityWithBlobTests : 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) : Entity(id) { + var content by BlobTable.blob + + companion object : EntityClass(BlobTable) + } + + @Test + fun testBlobField() { + withTables(BlobTable) { + val y1 = BlobEntity.new { + content = ExposedBlob("foo".toByteArray()) + } + + 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/v1/dao/r2dbc/tests/shared/ForeignIdEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/ForeignIdEntityTest.kt new file mode 100644 index 0000000000..92207b8a3c --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/ForeignIdEntityTest.kt @@ -0,0 +1,96 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntity +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntityClass +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 ForeignIdEntityTest : 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) : LongEntity(id) { + companion object : LongEntityClass(Schema.Projects) + + var name by Schema.Projects.name + } + + class ProjectConfig(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(Schema.ProjectConfigs) + + var setting by Schema.ProjectConfigs.setting + } + + class Actor(id: EntityID) : Entity(id) { + companion object : EntityClass(Schema.Actors) + + val roles by Role referrersOn Schema.Roles.actor + } + + class Role(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Schema.Roles) + + val actor by Actor referencedOn Schema.Roles.actor + } + + @Test + fun foreignIdEntityUpdate() { + // reproducer for https://github.com/JetBrains/Exposed/issues/880 + withTables(Schema.Projects, Schema.ProjectConfigs, configure = { useNestedTransactions = true }) { + suspendTransaction { + val projectId = Project.new { name = "Space" }.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/v1/dao/r2dbc/tests/shared/IdentifierManagerConcurrencyTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/IdentifierManagerConcurrencyTest.kt new file mode 100644 index 0000000000..d145b3daa3 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/IdentifierManagerConcurrencyTest.kt @@ -0,0 +1,59 @@ +package org.jetbrains.exposed.v1.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 IdentifierManagerConcurrencyTest : 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/v1/dao/r2dbc/tests/shared/JavaUUIDTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/JavaUUIDTableEntityTest.kt new file mode 100644 index 0000000000..fe25aa55cb --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/JavaUUIDTableEntityTest.kt @@ -0,0 +1,200 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.java.UUIDEntity +import org.jetbrains.exposed.v1.dao.r2dbc.java.UUIDEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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 JavaUUIDTableEntityTest : R2dbcDatabaseTestsBase() { + + @Suppress("MemberNameEqualsClassName") + object JavaUUIDTables { + object Cities : UUIDTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(Cities, null, null) + + var name by Cities.name + val towns by Town referrersOn Towns.cityId + } + + object People : UUIDTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(People) + + var name by People.name + val city by City referencedOn 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) : UUIDEntity(id) { + companion object : UUIDEntityClass
(Addresses) + + val person by Person.referencedOn(Addresses.person) + val city by City.referencedOn(Addresses.city) + var address by Addresses.address + } + + object Towns : UUIDTable("towns") { + val cityId: Column = javaUUID("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UUIDEntity(id) { + companion object : UUIDEntityClass(Towns) + + val city by City referencedOn 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/v1/dao/r2dbc/tests/shared/LongIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/LongIdTableEntityTest.kt new file mode 100644 index 0000000000..4e0cd78439 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/LongIdTableEntityTest.kt @@ -0,0 +1,151 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.LongEntity +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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 LongIdTableEntityTest : R2dbcDatabaseTestsBase() { + object LongIdTables { + object Cities : LongIdTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOn Towns.cityId + } + + object People : LongIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(People) + + var name by People.name + val city by City referencedOn People.cityId + } + + object Towns : LongIdTable("towns") { + val cityId: Column = long("city_id").references(Cities.id) + } + + class Town(id: EntityID) : LongEntity(id) { + companion object : LongEntityClass(Towns) + + val city by City referencedOn 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/v1/dao/r2dbc/tests/shared/NewDeferredBatchTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/NewDeferredBatchTest.kt new file mode 100644 index 0000000000..2f1aa0032e --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/NewDeferredBatchTest.kt @@ -0,0 +1,109 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flattenConcat +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +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.StatementType +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.SchemaUtils +import org.jetbrains.exposed.v1.r2dbc.statements.SuspendStatementInterceptor +import org.jetbrains.exposed.v1.r2dbc.statements.api.R2dbcPreparedStatementApi +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.transactions.inTopLevelSuspendTransaction +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class NewDeferredBatchTest : R2dbcDatabaseTestsBase() { + object Items : IntIdTable("items_ndb") { + val name = varchar("name", 50) + } + + class ItemEntity(id: EntityID) : IntEntity(id) { + var name by Items.name + companion object : IntEntityClass(Items) + } + + private class InsertExecutionCounter : SuspendStatementInterceptor { + var count = 0 + override suspend fun afterExecution( + transaction: R2dbcTransaction, + contexts: List, + executedStatement: R2dbcPreparedStatementApi + ) { + if (contexts.firstOrNull()?.statement?.type == StatementType.INSERT) count++ + } + } + + @Test + fun testBatchInsertOnSingleFlush() { + withTables(Items) { + val counter = InsertExecutionCounter() + registerInterceptor(counter) + + val deferred = (1..5).map { i -> + ItemEntity.newDeferred { name = "item$i" } + } + assertEquals(0, counter.count, "no INSERT should run before collection") + + val entities = deferred.asFlow().flattenConcat().toList() + assertEquals(1, counter.count, "all 5 entities are persisted by a single batch INSERT") + + entities.forEach { it.name } + assertEquals(1, counter.count, "no additional INSERT after collection") + + assertEquals(listOf("item1", "item2", "item3", "item4", "item5"), entities.map { it.name }) + entities.forEach { assertNotNull(it.id._value, "id must be populated") } + } + } + + @Test + fun testCollectWithoutTransactionInContextFails() = withConnection { database, _ -> + suspendTransaction(database) { SchemaUtils.create(Items) } + try { + val deferred = suspendTransaction(database) { + maxAttempts = 1 + ItemEntity.newDeferred { name = "escaped" } + } + + val failure = assertFailsWith { deferred.toList() } + assertContains(assertNotNull(failure.message), "no transaction is in context") + } finally { + suspendTransaction(database) { SchemaUtils.drop(Items) } + } + } + + @Test + fun testCollectInDifferentTransactionFails() { + withTables(Items) { + val deferred = inTopLevelSuspendTransaction(null) { + maxAttempts = 1 + ItemEntity.newDeferred { name = "escaped" } + } + + val failure = assertFailsWith { deferred.toList() } + assertContains(assertNotNull(failure.message), "must be collected inside the transaction") + } + } + + @Test + fun testCollectInNestedTransactionSucceeds() { + withTables(Items) { + val deferred = ItemEntity.newDeferred { name = "nested" } + + val entity = suspendTransaction { deferred.single() } + + assertEquals("nested", entity.name) + assertNotNull(entity.id._value, "id must be populated") + } + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/NonAutoIncEntities.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/NonAutoIncEntities.kt new file mode 100644 index 0000000000..1d4e9ff235 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/NonAutoIncEntities.kt @@ -0,0 +1,129 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.any +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import org.jetbrains.exposed.v1.r2dbc.update +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test + +class NonAutoIncEntities : 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) : Entity(id) { + var b1 by NotAutoIntIdTable.b1 + var defaultedInNew by NotAutoIntIdTable.defaultedInt + + companion object : EntityClass(NotAutoIntIdTable) { + val lastId = AtomicInteger(0) + internal const val defaultInt = 42 + suspend fun new(b: Boolean) = new(lastId.incrementAndGet()) { b1 = b } + + override suspend fun new(id: Int?, init: suspend 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) + + 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) : Entity(id) { + companion object : EntityClass(CustomPrimaryKeyColumnTable) + + var customId by CustomPrimaryKeyColumnTable.customId + } + + @Test + fun testIdValueIsTheSameAsCustomPrimaryKeyColumn() { + withTables(CustomPrimaryKeyColumnTable) { + val request = CustomPrimaryKeyColumnEntity.new { + customId = "customIdValue" + } + + 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) : Entity(id) { + companion object : EntityClass(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/v1/dao/r2dbc/tests/shared/OrderedReferenceTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/OrderedReferenceTest.kt new file mode 100644 index 0000000000..0bdaf58729 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/OrderedReferenceTest.kt @@ -0,0 +1,247 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.load +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 OrderedReferenceTest : 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) : IntEntity(id) { + companion object : IntEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserDefaultOrder referencedOn UserRatings.user + } + + class UserNullableRatingDefaultOrder(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(UserNullableRatings) + + var value by UserNullableRatings.value + val user by UserDefaultOrder optionalReferencedOn UserNullableRatings.user + } + + class UserDefaultOrder(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Users) + + val ratings by UserRatingDefaultOrder referrersOn UserRatings.user orderBy UserRatings.value + val nullableRatings by UserNullableRatingDefaultOrder optionalReferrersOn 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) : IntEntity(id) { + companion object : IntEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserMultiColumn referencedOn UserRatings.user + } + + class UserNullableRatingMultiColumn(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(UserNullableRatings) + + var value by UserNullableRatings.value + val user by UserMultiColumn optionalReferencedOn UserNullableRatings.user + } + + class UserMultiColumn(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Users) + + val ratings by UserRatingMultiColumn + .referrersOn(UserRatings.user) + .orderBy(UserRatings.value to SortOrder.DESC, UserRatings.id to SortOrder.DESC) + val nullableRatings by UserNullableRatingMultiColumn + .optionalReferrersOn(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..) : IntEntity(id) { + companion object : IntEntityClass(UserRatings) + + var value by UserRatings.value + val user by UserChainedColumn referencedOn UserRatings.user + } + + class UserChainedColumn(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Users) + + val ratings by UserRatingChainedColumn referrersOn 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/v1/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt new file mode 100644 index 0000000000..ab2b5128d8 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcEntityBugsRegressionTest.kt @@ -0,0 +1,174 @@ +@file: Suppress("MatchingDeclarationName", "Filename", "ClassNaming") + +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntity +import org.jetbrains.exposed.v1.dao.r2dbc.LongEntityClass +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) : IntEntity(id) { + var first: String by NamesTable.first + var second: String by NamesTable.second + + companion object : IntEntityClass(NamesTable) + } + + class Accounts(id: EntityID) : IntEntity(id) { + val name by Names referencedOn AccountsTable.name + + companion object : EntityClass(AccountsTable) { + + suspend 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) : Entity(id) + + abstract class TextEntityClass(table: IdTable, entityType: Class? = null) : EntityClass(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) : LongEntity(id) { + companion object : LongEntityClass(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 referencedOn 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) : IntEntity(id) { + var value by TestTable.value + + companion object : IntEntityClass(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-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt new file mode 100644 index 0000000000..8b7871b408 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcUIntIdTableEntityTest.kt @@ -0,0 +1,152 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.UIntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.UIntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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 UIntIdTableEntityTest : 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) : UIntEntity(id) { + companion object : UIntEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOn Towns.cityId + } + + object People : UIntIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UIntEntity(id) { + companion object : UIntEntityClass(People) + + var name by People.name + val city by City referencedOn People.cityId + } + + object Towns : UIntIdTable("towns") { + val cityId: Column = uinteger("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UIntEntity(id) { + companion object : UIntEntityClass(Towns) + + val city by City referencedOn Towns.cityId + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt new file mode 100644 index 0000000000..c71a6116ea --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcULongIdTableEntityTest.kt @@ -0,0 +1,156 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.ULongEntity +import org.jetbrains.exposed.v1.dao.r2dbc.ULongEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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 ULongIdTableEntityTest : 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) : ULongEntity(id) { + companion object : ULongEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOn Towns.cityId + } + + object People : ULongIdTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : ULongEntity(id) { + companion object : ULongEntityClass(People) + + var name by People.name + val city by City referencedOn People.cityId + } + + object Towns : ULongIdTable("towns") { + val cityId: Column = ulong("city_id").references(Cities.id) + } + + class Town(id: EntityID) : ULongEntity(id) { + companion object : ULongEntityClass(Towns) + + val city by City referencedOn Towns.cityId + } +} diff --git a/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcViaTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcViaTest.kt new file mode 100644 index 0000000000..bbecb2edb7 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/R2dbcViaTest.kt @@ -0,0 +1,472 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import io.r2dbc.spi.IsolationLevel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.CompositeEntity +import org.jetbrains.exposed.v1.dao.r2dbc.CompositeEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.UuidEntity +import org.jetbrains.exposed.v1.dao.r2dbc.UuidEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +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.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) : UuidEntity(id) { + var number by ViaTestData.NumbersTable.number + var connectedStrings by VString via ViaTestData.ConnectionTable + var connectedAutoStrings by VString via ViaTestData.ConnectionAutoIncTable + + companion object : UuidEntityClass(ViaTestData.NumbersTable) +} + +class VString(id: EntityID) : Entity(id) { + var text by ViaTestData.StringsTable.text + + companion object : EntityClass(ViaTestData.StringsTable) +} + +class ViaTest : 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 = SizedCollection(valuesToSet) + } else { + connectedAutoStrings = SizedCollection(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)) + } + } + } + + /** + * Assigning a many-to-many relation is queued rather than executed, because a property setter + * cannot suspend. Reading the relation back in the same transaction must still observe the + * assignment, without the caller having to reach for `flushCache()`. + * + * The second assignment is the interesting one: by then the first read has populated the + * referrers cache, so a stale cached collection could be returned. + */ + @Test + fun testReadBackAssignedLinksWithoutExplicitFlush() { + withTables(*ViaTestData.allTables) { + val n = VNumber.new { number = 10 } + val s1 = VString.new { text = "aaa" } + val s2 = VString.new { text = "bbb" } + + n.connectedStrings = SizedCollection(listOf(s1, s2)) + assertEqualCollections(listOf("aaa", "bbb"), n.connectedStrings.toList().map { it.text }) + + n.connectedStrings = SizedCollection(listOf(s2)) + assertEqualCollections(listOf("bbb"), n.connectedStrings.toList().map { it.text }) + + n.connectedStrings = SizedCollection(emptyList()) + assertEqualCollections(emptyList(), n.connectedStrings.toList().map { it.text }) + } + } + + /** + * Same contract as [testReadBackAssignedLinksWithoutExplicitFlush], in the shape a web handler + * actually uses it: the parent is loaded with `findById` in a fresh transaction, the targets are + * created during the same transaction, and the relation is read back through `Flow.map`. + */ + @Test + fun testReadBackAssignedLinksAfterFindByIdWithoutExplicitFlush() { + withTables(*ViaTestData.allTables) { + val numberId = VNumber.new { number = 7 }.id + + inTopLevelSuspendTransaction { + val n = VNumber.findById(numberId)!! + val targets = listOf("aaa", "bbb").map { value -> VString.new { text = value } } + + n.connectedStrings = SizedCollection(targets) + assertEqualCollections(listOf("aaa", "bbb"), n.connectedStrings.map { it.text }.toList()) + } + } + } + + /** + * A queued many-to-many assignment must not survive a rollback and be replayed by a later flush. + * + * This holds for a non-obvious reason: the queue lives on the entity cache, and the cache is + * discarded when the transaction rolls back — it is not cleared explicitly in `beforeRollback` + * alongside `data`/`inserts`/`updates`. The test pins the behaviour so a change to cache lifetime + * cannot silently start resurrecting rolled-back writes. + */ + @Test + fun testPendingLinkUpdatesAreDiscardedOnRollback() { + withTables(*ViaTestData.allTables) { + val numberId = VNumber.new { number = 1 }.id + val stringId = VString.new { text = "aaa" }.id + commit() + + inTopLevelSuspendTransaction { + maxAttempts = 1 + val n = VNumber.findById(numberId)!! + val s = VString.findById(stringId)!! + n.connectedStrings = SizedCollection(listOf(s)) + rollback() + flushCache() + assertEquals(0L, ViaTestData.ConnectionTable.selectAll().count()) + } + + assertEquals(0L, ViaTestData.ConnectionTable.selectAll().count()) + } + } + + 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) : IntEntity(id) { + companion object : IntEntityClass(NodesTable) + + var name by NodesTable.name + var parents by Node.via(NodeToNodes.child, NodeToNodes.parent) + var children by Node.via(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 child1 = Node.new { + name = "child1" + parents = SizedCollection( + Node.new { name = "root" } + ) + } + + val root = child1.parents.single() + + assertEquals(0L, root.parents.count()) + assertEquals(1L, root.children.count()) + + val child2 = Node.new { name = "child2" } + root.children = SizedCollection(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" + children = SizedCollection(child1) + } + val root2 = Node.new { + name = "root2" + children = SizedCollection(child1, child2) + } + + entityCache.clear(flush = true) + + suspend fun checkChildrenReferences(node: Node, values: List) { + val children = entityCache.getReferrers(node.id, NodeToNodes.parent) + 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 children = entityCache.getReferrers(node.id, NodeToNodes.child) + 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) : IntEntity(id) { + companion object : IntEntityClass(NodesTable) + + var name by NodesTable.name + var parents by NodeOrdered.via(NodeToNodes.child, NodeToNodes.parent) + var children by NodeOrdered.via(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 + parents = SizedCollection(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) : IntEntity(id) { + companion object : IntEntityClass(Projects) + + var name by Projects.name + var tasks by Task via 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) : CompositeEntity(id) { + companion object : CompositeEntityClass(ProjectTasks) + + var approved by ProjectTasks.approved + } + + object Tasks : IntIdTable("tasks") { + val title = varchar("title", 64) + } + + class Task(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/shared/SelfReferenceTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/SelfReferenceTest.kt new file mode 100644 index 0000000000..72a2a77113 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/SelfReferenceTest.kt @@ -0,0 +1,105 @@ +package org.jetbrains.exposed.v1.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 SelfReferenceTest { + + @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/v1/dao/r2dbc/tests/shared/UuidTableEntityTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/UuidTableEntityTest.kt new file mode 100644 index 0000000000..d1907fd0fb --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/UuidTableEntityTest.kt @@ -0,0 +1,247 @@ +package org.jetbrains.exposed.v1.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.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.dao.r2dbc.UuidEntity +import org.jetbrains.exposed.v1.dao.r2dbc.UuidEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.with +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 UuidTableEntityTest : R2dbcDatabaseTestsBase() { + @Suppress("MemberNameEqualsClassName") + object UuidTables { + object Cities : UuidTable() { + val name = varchar("name", 50) + } + + class City(id: EntityID) : UuidEntity(id) { + companion object : UuidEntityClass(Cities) + + var name by Cities.name + val towns by Town referrersOn Towns.cityId + } + + object People : UuidTable() { + val name = varchar("name", 80) + val cityId = reference("city_id", Cities) + } + + class Person(id: EntityID) : UuidEntity(id) { + companion object : UuidEntityClass(People) + + var name by People.name + val city by City referencedOn 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) : UuidEntity(id) { + companion object : UuidEntityClass
(Addresses) + + val person by Person.referencedOn(Addresses.person) + val city by City.referencedOn(Addresses.city) + var address by Addresses.address + } + + object Towns : UuidTable("towns") { + val cityId: Column = uuid("city_id").references(Cities.id) + } + + class Town(id: EntityID) : UuidEntity(id) { + companion object : UuidEntityClass(Towns) + + val city by City referencedOn 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) : UuidEntity(id) { + companion object : UuidEntityClass(Books) + + var title by Books.title + var ssid by Books.ssid + var pubId by Books.pubId + val pubCity by City referencedOn 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/v1/dao/r2dbc/tests/shared/WarmUpLinkedReferencesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/WarmUpLinkedReferencesTests.kt new file mode 100644 index 0000000000..a86f3813cd --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/WarmUpLinkedReferencesTests.kt @@ -0,0 +1,63 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.r2dbc.tests.R2dbcDatabaseTestsBase +import org.jetbrains.exposed.v1.r2dbc.tests.shared.assertEquals +import kotlin.test.Test + +class WarmUpLinkedReferencesTests : R2dbcDatabaseTestsBase() { + + object Box : IntIdTable() { + val value = integer("value") + } + + class EBox(id: EntityID) : IntEntity(id) { + var value by Box.value + + companion object : IntEntityClass(Box) + } + + object BoxItem : IntIdTable() { + val box = reference("box", Box) + + val value = integer("value") + } + + class EBoxItem(id: EntityID) : IntEntity(id) { + var value by BoxItem.value + val box by EBox referencedOn BoxItem.box + + companion object : IntEntityClass(BoxItem) + } + + @Test + fun warmUpLinkedReferencesShouldNotReturnAllTheValueFromCache() { + withTables(Box, BoxItem) { + val boxEntities = (0..4).map { + EBox.new { + value = it + } + } + + boxEntities.forEach { boxEntity -> + EBoxItem.new { + value = boxEntity.id.value + box.set(boxEntity) + } + } + flushCache() + + 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-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/ddl/SequencesTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/ddl/SequencesTests.kt new file mode 100644 index 0000000000..67f70da9fa --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/ddl/SequencesTests.kt @@ -0,0 +1,54 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.ddl + +import kotlinx.coroutines.test.runTest +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.UuidTable +import org.jetbrains.exposed.v1.dao.r2dbc.UuidEntity +import org.jetbrains.exposed.v1.dao.r2dbc.UuidEntityClass +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 SequencesTests : R2dbcDatabaseTestsBase() { + object TesterTable : UuidTable("Tester") { + val index = integer("index").autoIncrement() + val name = text("name") + } + + class TesterEntity(id: EntityID) : UuidEntity(id) { + companion object : UuidEntityClass(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/v1/dao/r2dbc/tests/shared/dml/ColumnWithTransformTest.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/ColumnWithTransformTest.kt new file mode 100644 index 0000000000..40075b147b --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/ColumnWithTransformTest.kt @@ -0,0 +1,92 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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 ColumnWithTransformTest : 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) : IntEntity(id) { + var simple by TransformTable.simple + var chained by TransformTable.chained + + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/shared/dml/InsertTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/InsertTests.kt new file mode 100644 index 0000000000..08ae290fed --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/InsertTests.kt @@ -0,0 +1,88 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.EntityTests +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 InsertTests : R2dbcDatabaseTestsBase() { + private object OrderedDataTable : IntIdTable() { + val name = text("name") + val order = integer("order") + } + + class OrderedData(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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(EntityTests.Posts) { + val id1 = EntityTests.Posts.insertAndGetId { + it[board] = null + it[category] = null + } + + val inserted1 = EntityTests.Posts.selectAll().where { EntityTests.Posts.id eq id1 }.single() + assertNull(inserted1[EntityTests.Posts.board]) + assertNull(inserted1[EntityTests.Posts.category]) + + val boardId = EntityTests.Boards.insertAndGetId { + it[name] = Uuid.random().toString() + } + val categoryId = EntityTests.Categories.insert { + it[title] = "Category" + }[EntityTests.Categories.uniqueId] + + val id2 = EntityTests.Posts.insertAndGetId { + it[board] = Op.nullOp() + it[category] = categoryId + it[board] = boardId.value + } + + EntityTests.Posts.deleteWhere { EntityTests.Posts.id eq id2 } + + val nullableCategoryID: Uuid? = categoryId + val nullableBoardId: Int? = boardId.value + EntityTests.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/v1/dao/r2dbc/tests/shared/dml/ReturningTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/ReturningTests.kt new file mode 100644 index 0000000000..c98e5d511e --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/ReturningTests.kt @@ -0,0 +1,58 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 ReturningTests : 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) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/shared/dml/SelectTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/SelectTests.kt new file mode 100644 index 0000000000..cfadbd0dad --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/dml/SelectTests.kt @@ -0,0 +1,45 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.dml + +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.notInList +import org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.EntityTests +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 SelectTests : R2dbcDatabaseTestsBase() { + @Test + fun testInListWithEntityIDColumns() { + withTables(EntityTests.Posts, EntityTests.Boards, EntityTests.Categories) { + val board1 = EntityTests.Board.new { + this.name = "Board1" + } + + val post1 = EntityTests.Post.new { + this.board.set(board1) + } + + EntityTests.Post.new { + category.set(EntityTests.Category.new { title = "Category1" }) + } + + val result1 = EntityTests.Posts.selectAll().where { + EntityTests.Posts.board inList listOf(board1.id) + }.singleOrNull()?.get(EntityTests.Posts.id) + assertEquals(post1.id, result1) + + val result2 = EntityTests.Board.find { + EntityTests.Boards.id inList listOf(1, 2, 3, 4, 5) + }.singleOrNull() + assertEquals(board1, result2) + + val result3 = EntityTests.Board.find { + EntityTests.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/v1/dao/r2dbc/tests/shared/types/ArrayColumnTypeTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/types/ArrayColumnTypeTests.kt new file mode 100644 index 0000000000..e3d06b8309 --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/types/ArrayColumnTypeTests.kt @@ -0,0 +1,63 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.types + +import kotlinx.coroutines.flow.single +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.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 ArrayColumnTypeTests : 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) : IntEntity(id) { + companion object : IntEntityClass(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/v1/dao/r2dbc/tests/shared/types/VectorColumnTypeTests.kt b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/types/VectorColumnTypeTests.kt new file mode 100644 index 0000000000..e011c209fa --- /dev/null +++ b/exposed-dao-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/tests/shared/types/VectorColumnTypeTests.kt @@ -0,0 +1,66 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared.types + +import kotlinx.coroutines.flow.single +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass +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 VectorColumnTypeTests : 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) : IntEntity(id) { + companion object : IntEntityClass(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/api/exposed-dao-r2dbc.api b/exposed-dao-r2dbc/api/exposed-dao-r2dbc.api new file mode 100644 index 0000000000..c2636532e4 --- /dev/null +++ b/exposed-dao-r2dbc/api/exposed-dao-r2dbc.api @@ -0,0 +1,360 @@ +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/CompositeEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/CompositeEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/DaoEntityID : 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/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V + public fun delete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun flush (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun flush$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate;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/v1/dao/r2dbc/EntityClass; + public final fun getReadValues ()Lorg/jetbrains/exposed/v1/core/ResultRow; + public final fun getValue (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Ljava/lang/Object; + public final fun getValue (Lorg/jetbrains/exposed/v1/core/CompositeColumn;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Ljava/lang/Object; + public final fun getValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;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/v1/dao/r2dbc/Entity;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun setValue (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;Ljava/lang/Object;)V + public final fun setValue (Lorg/jetbrains/exposed/v1/core/CompositeColumn;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;Ljava/lang/Object;)V + public final fun setValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;Ljava/lang/Object;)V + public final fun set_readValues (Lorg/jetbrains/exposed/v1/core/ResultRow;)V + public final fun storeWrittenValues ()V + public final fun via (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; + public final fun via (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Table;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate { + public fun (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;)V + public final fun addBatch (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public final fun execute (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun set (Lorg/jetbrains/exposed/v1/core/Column;Ljava/lang/Object;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityCache { + public fun (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;)V + public final fun clear (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun clear$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityCache;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun clearReferrersCache ()V + public final fun find (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun findAll (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;)Ljava/util/List; + 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/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/Column;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getReferrers (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun remove (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public final fun scheduleInsert (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public final fun scheduleUpdate (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public final fun setMaxEntitiesToStore (I)V + public final fun store (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public final fun store (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityCacheKt { + public static final fun flushCache (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun getEntityCache (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityCache; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityChange { + public fun (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType;Ljava/lang/String;)V + public final fun component1 ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun component2 ()Lorg/jetbrains/exposed/v1/core/dao/id/EntityID; + public final fun component3 ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType;Ljava/lang/String;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChange; + public static synthetic fun copy$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChange;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType;Ljava/lang/String;ILjava/lang/Object;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChange; + public fun equals (Ljava/lang/Object;)Z + public final fun getChangeType ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public final fun getEntityClass ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getEntityId ()Lorg/jetbrains/exposed/v1/core/dao/id/EntityID; + public final fun getTransactionId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType : java/lang/Enum { + public static final field Created Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public static final field Removed Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public static final field Updated Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; + public static fun values ()[Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType; +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/SizedIterable; + public final fun attach (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun attach$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun backReferencedOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference; + public final fun backReferencedOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference; + public final fun count (Lorg/jetbrains/exposed/v1/core/Op;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun count$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Op;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + protected fun createInstance (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun find (Lkotlin/jvm/functions/Function0;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun find (Lorg/jetbrains/exposed/v1/core/Op;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun findById (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun findById (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun findByIdAndUpdate (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun findSingleByAndUpdate (Lorg/jetbrains/exposed/v1/core/Op;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forEntityIds (Ljava/util/List;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun forIds (Ljava/util/List;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + 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 final fun isAssignableTo (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;)Z + public final fun memoizedTransform (Lorg/jetbrains/exposed/v1/core/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + public final fun memoizedTransform (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/ColumnTransformer;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + public final fun memoizedTransform (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + public fun new (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun new (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun newDeferred (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; + public fun newDeferred (Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; + public final fun optionalBackReferencedOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalBackReference; + public final fun optionalBackReferencedOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalBackReference; + public final fun optionalBackReferencedOnNonNullable (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalBackReference; + public final fun optionalReferencedOn (Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalReference; + public final fun optionalReferencedOn (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalReference; + public final fun optionalReferrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun optionalReferrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;Z)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun optionalReferrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun referencedOn (Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Reference; + public final fun referencedOn (Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Reference; + public final fun referrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun referrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;Z)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun referrersOn (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun reload (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun reload$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun removeFromCache (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)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/v1/dao/r2dbc/Entity; + public final fun transform (Lorg/jetbrains/exposed/v1/core/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + public final fun transform (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/ColumnTransformer;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + public final fun transform (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform; + protected fun warmCache ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityCache; + public final fun warmUpLinkedReferences (Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Table;Ljava/lang/Boolean;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun warmUpLinkedReferences$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Table;Ljava/lang/Boolean;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun warmUpOptReferences (Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Column;[Lkotlin/Pair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun warmUpOptReferences$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Column;[Lkotlin/Pair;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun warmUpReferences (Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Column;[Lkotlin/Pair;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun warmUpReferences$default (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/List;Lorg/jetbrains/exposed/v1/core/Column;[Lkotlin/Pair;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun wrap (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun wrapRow (Lorg/jetbrains/exposed/v1/core/ResultRow;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun wrapRow (Lorg/jetbrains/exposed/v1/core/ResultRow;Lorg/jetbrains/exposed/v1/core/Alias;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun wrapRow (Lorg/jetbrains/exposed/v1/core/ResultRow;Lorg/jetbrains/exposed/v1/core/QueryAlias;)Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun wrapRows (Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; +} + +public class org/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform : org/jetbrains/exposed/v1/core/ColumnTransformer { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/ColumnTransformer;Z)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/ColumnTransformer;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + protected final fun getCacheResult ()Z + public final fun getColumn ()Lorg/jetbrains/exposed/v1/core/Column; + public fun unwrap (Ljava/lang/Object;)Ljava/lang/Object; + public fun wrap (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityHook { + public static final field INSTANCE Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityHook; + public final fun subscribe (Lkotlin/jvm/functions/Function2;)Lkotlin/jvm/functions/Function2; + public final fun unsubscribe (Lkotlin/jvm/functions/Function2;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityHookKt { + public static final fun alertSubscribers (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun registerChange (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChangeType;)V + public static final fun registeredChanges (Lorg/jetbrains/exposed/v1/r2dbc/R2dbcTransaction;)Ljava/util/List; + public static final fun toEntity (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChange;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun toEntity (Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityChange;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun withHook (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/EntityLifecycleInterceptor : 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 abstract interface annotation class org/jetbrains/exposed/v1/dao/r2dbc/ExperimentalR2dbcDaoApi : java/lang/annotation/Annotation { +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/IntEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/IntEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/LongEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/LongEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/UIntEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/UIntEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/ULongEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/ULongEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/UuidEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/UuidEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/exceptions/EntityNotFoundException : java/lang/Exception { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;)V + public final fun getEntity ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getId ()Lorg/jetbrains/exposed/v1/core/dao/id/EntityID; +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/java/UUIDEntity : org/jetbrains/exposed/v1/dao/r2dbc/Entity { + public fun (Lorg/jetbrains/exposed/v1/core/dao/id/EntityID;)V +} + +public abstract class org/jetbrains/exposed/v1/dao/r2dbc/java/UUIDEntityClass : org/jetbrains/exposed/v1/dao/r2dbc/EntityClass { + 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/v1/dao/r2dbc/relationships/Accessor { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getValue (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Accessor; + public final fun invoke (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun set (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lkotlin/jvm/functions/Function1; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/EagerLoadingKt { + public static final fun load (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;[Lkotlin/reflect/KProperty1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun with (Ljava/lang/Iterable;[Lkotlin/reflect/KProperty1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun with (Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable;[Lkotlin/reflect/KProperty1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink { + public fun (Lorg/jetbrains/exposed/v1/core/Table;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/Column;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Table;Lorg/jetbrains/exposed/v1/core/dao/id/IdTable;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/core/Column;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSourceColumn ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getTable ()Lorg/jetbrains/exposed/v1/core/Table; + public final fun getTarget ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getTargetColumn ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun orderBy (Ljava/util/List;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; + public final fun orderBy (Lkotlin/Pair;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; + public final fun orderBy (Lorg/jetbrains/exposed/v1/core/Expression;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; + public final fun provideDelegate (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLinkAccessor; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLinkAccessor : org/jetbrains/exposed/v1/r2dbc/SizedIterable { + public fun (Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V + public fun collect (Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun copy ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public fun count (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun empty (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun forUpdate (Lorg/jetbrains/exposed/v1/core/vendors/ForUpdateOption;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun getEntity ()Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity; + public final fun getLink ()Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink; + public final fun getValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public fun limit (I)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public fun notForUpdate ()Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public fun offset (J)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public fun orderBy ([Lkotlin/Pair;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun setValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalAccessor { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getValue (Ljava/lang/Object;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalAccessor; + public final fun invoke (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun set (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;)V +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalBackReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lkotlin/jvm/functions/Function1; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalReference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFactory ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getReferences ()Ljava/util/Map; + public final fun provideDelegate (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/OptionalAccessor; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/Reference { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getFactory ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getReferences ()Ljava/util/Map; + public final fun provideDelegate (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Accessor; +} + +public final class org/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers { + public fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;ZLjava/util/Map;)V + public synthetic fun (Lorg/jetbrains/exposed/v1/core/Column;Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass;ZLjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAllReferences ()Ljava/util/Map; + public final fun getCache ()Z + public final fun getFactory ()Lorg/jetbrains/exposed/v1/dao/r2dbc/EntityClass; + public final fun getReference ()Lorg/jetbrains/exposed/v1/core/Column; + public final fun getValue (Lorg/jetbrains/exposed/v1/dao/r2dbc/Entity;Lkotlin/reflect/KProperty;)Lorg/jetbrains/exposed/v1/r2dbc/SizedIterable; + public final fun orderBy (Ljava/util/List;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun orderBy (Lkotlin/Pair;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun orderBy (Lorg/jetbrains/exposed/v1/core/Expression;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; + public final fun orderBy ([Lkotlin/Pair;)Lorg/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers; +} + diff --git a/exposed-dao-r2dbc/build.gradle.kts b/exposed-dao-r2dbc/build.gradle.kts new file mode 100644 index 0000000000..88ed17d06c --- /dev/null +++ b/exposed-dao-r2dbc/build.gradle.kts @@ -0,0 +1,32 @@ +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")) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-opt-in=org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi") + } +} + +tasks.withType().configureEach { + targetCompatibility = "11" +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/CompositeEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/CompositeEntity.kt new file mode 100644 index 0000000000..5e54edd65d --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/CompositeEntity.kt @@ -0,0 +1,30 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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 + +/** Base class for an [Entity] instance identified by an [id] comprised of multiple wrapped values. */ +@ExperimentalR2dbcDaoApi +abstract class CompositeEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [CompositeEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [CompositeEntity] type. This can be left `null` if it is the class of type argument + * [E] provided to this [CompositeEntityClass] instance. If this `CompositeEntityClass` is defined as a companion object + * of a custom `CompositeEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate a [CompositeEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will be used + * to determine the primary constructor of the associated entity class on first access. If this `CompositeEntityClass` + * is defined as a companion object of a custom `CompositeEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class CompositeEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/DaoEntityID.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/DaoEntityID.kt new file mode 100644 index 0000000000..dc05c4dd0e --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/DaoEntityID.kt @@ -0,0 +1,11 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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]. + */ +@ExperimentalR2dbcDaoApi +class DaoEntityID(id: T?, table: IdTable) : EntityID(table, id) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/Entity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/Entity.kt new file mode 100644 index 0000000000..139b50328c --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/Entity.kt @@ -0,0 +1,335 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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 +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.dao.r2dbc.exceptions.EntityNotFoundException +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.InnerTableLink +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 +import kotlin.properties.Delegates +import kotlin.reflect.KProperty + +/** + * Class representing a mapping to values stored in a table record in a database. + * + * @param id The unique stored identity value for the mapped record. + */ +@ExperimentalR2dbcDaoApi +open class Entity(val id: EntityID) { + + /** The associated [EntityClass] that manages this [Entity] instance. */ + var klass: EntityClass> by Delegates.notNull() + internal set + + /** The [R2dbcDatabase] associated with the record mapped to this [Entity] instance. */ + var db: R2dbcDatabase by Delegates.notNull() + internal set + + /** + * The initial column-value mapping for this [Entity] instance before being flushed and inserted into the database. + * + * These values are transferred to [readValues] before being sent to the database during a flush operation. + * In case of a transaction failure, both [writeValues] and [readValues] are cleared before rollback + * to ensure that no stale data is carried over into a new transaction. + */ + val writeValues = LinkedHashMap, Any?>() + + @Suppress("VariableNaming") + var _readValues: ResultRow? = null + + /** The final column-value mapping for this [Entity] instance after being flushed and retrieved from the database. */ + 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: Entity, desc: KProperty<*>): T = lookup() + + /** + * Returns the value assigned to this column mapping. + * + * Depending on the state of this [Entity] instance, the value returned may be the initial property assignment, + * this column's default value, or the value retrieved from the database. + */ + 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[this] + } + + operator fun Column.setValue(entity: Entity, 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 + + 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) + } + } + } + } + + /** + * 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: Entity, 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: Entity, 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). + */ + operator fun EntityFieldWithTransform.getValue(o: Entity, 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: Entity, desc: KProperty<*>, value: Wrapped) { + column.setValue(o, desc, unwrap(value)) + } + + /** + * Stores a [value] for a [table] `id` column in this Entity's [writeValues] map. + * If the `id` column wraps a composite value, each non-null component value is stored for its component column. + */ + @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 fun isNewEntity(): Boolean { + val cache = TransactionManager.current().entityCache + return cache.inserts[klass.table]?.contains(this) ?: false + } + + /** Transfers initial column-value mappings from [writeValues] to [readValues] and clears the former once complete. */ + fun storeWrittenValues() { + 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 + } + } + writeValues.clear() + } + + /** + * Sends all cached inserts and updates for this [Entity] instance to the database. + * + * @param batch The [EntityBatchUpdate] instance that should be used to perform a batch update operation + * for multiple entities. If left `null`, a single update operation will be executed for this entity only. + * @return `false` if no cached inserts or updates were sent to the database; `true`, otherwise. + */ + @Suppress("ForbiddenComment") + open suspend fun flush(batch: EntityBatchUpdate? = null): Boolean { + if (isNewEntity()) { + TransactionManager.current().entityCache.flushInserts(klass.table) + return true + } + if (writeValues.isNotEmpty()) { + if (batch == null) { + val table = klass.table + + @Suppress("VariableNaming") + val _writeValues = writeValues.toMap() + storeWrittenValues() + + val transaction = TransactionManager.current() + + @Suppress("UNCHECKED_CAST") + transaction.registerChange(klass as EntityClass<*, Entity<*>>, id, EntityChangeType.Updated) + + executeAsPartOfEntityLifecycle { + table.update({ table.id eq id }) { + for ((c, v) in _writeValues) { + it[c] = v + } + } + } + } else { + batch.addBatch(this) + for ((c, v) in writeValues) { + batch[c] = v + } + storeWrittenValues() + } + + return true + } + return false + } + + /** + * Deletes this [Entity] instance, both from the cache and from the database. + * + * For entities that have not yet been flushed (i.e. still scheduled for insert), no DELETE statement + * is issued — the entity is simply removed from the scheduled inserts. This differs from JDBC, which + * issues an INSERT followed by a DELETE. + */ + open suspend fun delete() { + val table = klass.table + val entityId = this.id + + // This behaves differently from the JDBC module. In JDBC, the entity is inserted first and then + // removed from the database. Here we don't do that at the moment, and just remove it from cache if it was not inserted yet. + if (!isNewEntity()) { + val transaction = TransactionManager.current() + + @Suppress("UNCHECKED_CAST") + transaction.registerChange(klass as EntityClass<*, Entity<*>>, entityId, EntityChangeType.Removed) + + executeAsPartOfEntityLifecycle { + table.deleteWhere { table.id eq entityId } + } + } + + klass.removeFromCache(this) + } + + internal fun hasInReferenceCache(ref: Column<*>): Boolean { + return ref in referenceCache + } + + internal fun getReferenceFromCache(ref: Column<*>): T { + return referenceCache[ref] as T + } + + @Suppress("UNCHECKED_CAST") + internal fun resolveColumnValue(column: Column<*>): Any? = + writeValues[column as Column] + ?: _readValues?.getOrNull(column) + + internal fun storeReferenceInCache(ref: Column<*>, value: Any?) { + if (db.config.keepLoadedReferencesOutOfTransaction) { + referenceCache[ref] = value + } + } + + /** + * Updates the fields of this [Entity] instance with values retrieved from the database. + * Override this function to refresh some additional state, if any. + * + * @param flush Whether pending entity changes should be flushed prior to updating. + * @throws EntityNotFoundException If the entity no longer exists in the database. + */ + 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 EntityNotFoundException(this.id, this.klass) + else -> writeValues.clear() + } + + klass.removeFromCache(this) + val reloaded = klass[id] + cache.store(this) + _readValues = reloaded.readValues + db = transaction.db + } + + /** + * Registers an intermediate [table] as a many-to-many link between this entity's table and + * the target [EntityClass]. The source and target columns are inferred from the + * intermediate table's foreign keys. + * + * Counterpart of JDBC's `via`. + */ + infix fun > EntityClass.via( + table: Table + ): InnerTableLink, TID, Target> = + InnerTableLink( + table = table, + sourceTable = this@Entity.id.table, + target = this@via + ) + + /** + * 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. + */ + fun > EntityClass.via( + sourceColumn: Column>, + targetColumn: Column> + ): InnerTableLink, TID, Target> = + InnerTableLink( + table = sourceColumn.table, + sourceTable = this@Entity.id.table, + target = this@via, + _sourceColumn = sourceColumn, + _targetColumn = targetColumn + ) +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate.kt new file mode 100644 index 0000000000..c783de687b --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityBatchUpdate.kt @@ -0,0 +1,62 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.statements.BatchUpdateStatement +import org.jetbrains.exposed.v1.r2dbc.R2dbcTransaction +import org.jetbrains.exposed.v1.r2dbc.statements.BatchUpdateSuspendExecutable +import java.util.* + +/** + * Class responsible for performing a batch update operation on multiple instances of an [Entity] class. + * + * @param klass The [EntityClass] associated with the entities to batch update. + */ +@ExperimentalR2dbcDaoApi +class EntityBatchUpdate(private val klass: EntityClass<*, Entity<*>>) { + + private val data = ArrayList, SortedMap, Any?>>>() + + /** + * Adds the specified [entity] to the list of entities to batch update. + * + * The column-value mapping for this entity will initially be empty. + * Columns to update should be assigned by using the `set()` operator on this [EntityBatchUpdate] instance. + * + * @throws IllegalStateException If the entity being added cannot be associated with the [EntityClass] + * provided on instantiation of this [EntityBatchUpdate]. + */ + fun addBatch(entity: Entity<*>) { + if (entity.klass.table != klass.table) { + error( + "Table ${entity.klass.table.tableName} for entity class ${entity.klass} differs from expected table " + + "${klass.table.tableName} for entity class $klass" + ) + } + data.add(entity.id to TreeMap()) + } + + operator fun set(column: Column<*>, value: Any?) { + val values = data.last().second + + if (values.containsKey(column)) { + error("$column is already initialized") + } + + values[column] = value + } + + /** + * Executes the batch update SQL statement for each added entity in the provided [transaction] + * and returns the number of updated rows. + */ + suspend fun execute(transaction: R2dbcTransaction): Int { + val updateSets = data.filterNot { it.second.isEmpty() }.groupBy { it.second.keys } + return updateSets.values.fold(0) { acc, set -> + acc + BatchUpdateSuspendExecutable(BatchUpdateStatement(klass.table)).let { + it.statement.data.addAll(set) + it.execute(transaction) ?: 0 + } + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityCache.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityCache.kt new file mode 100644 index 0000000000..c6c23714fa --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityCache.kt @@ -0,0 +1,420 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import kotlinx.coroutines.flow.firstOrNull +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ResultRow +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.eq +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.batchInsert +import org.jetbrains.exposed.v1.r2dbc.selectAll +import java.util.LinkedHashMap +import java.util.concurrent.ConcurrentHashMap + +/** The current [EntityCache] for [this][R2dbcTransaction] scope, or a new instance if none exists. */ +@ExperimentalR2dbcDaoApi +val R2dbcTransaction.entityCache: EntityCache by transactionScope { + EntityCache(this as R2dbcTransaction) +} + +/** + * Class responsible for the storage of [Entity] instances in a specific [transaction]. + */ +@ExperimentalR2dbcDaoApi +class EntityCache(private val transaction: R2dbcTransaction) { + /** The mapping of [IdTable]s to associated [Entity] instances (as a mapping of entity id values to entities). */ + val data = ConcurrentHashMap, MutableMap>>() + + @Volatile + private var flushingEntities = false + + internal val inserts = ConcurrentHashMap, MutableSet>>() + + internal val updates = ConcurrentHashMap, MutableSet>>() + + internal val referrers = ConcurrentHashMap, MutableMap, SizedIterable<*>>>() + + // Queued rather than executed so that assigning a `via` relation stays non-suspend: + // a property setter cannot suspend, so the link writes are drained on flush instead. + internal val pendingInnerTableLinkUpdates = mutableListOf Unit>() + + /** + * Searches this [EntityCache] for an [Entity] by its [EntityID] value using its associated [EntityClass] as the key. + * + * @return The entity that has this wrapped id value, or `null` if no entity was found. + */ + fun > find(f: EntityClass, 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: EntityClass<*, *>): 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 + } + } + + /** + * The amount of entities to store in this [EntityCache] per [Entity] class. + * + * By default, this value is configured by `DatabaseConfig.maxEntitiesToStoreInCachePerEntity`, + * which defaults to storing all entities. + * + * On setting a new value, all data stored in the cache will be adjusted to the new size. If the new value + * is less than the current cache size by N, the first N entities stored will be removed. If the new value + * is greater than the current cache size, the adjusted cache will only be filled with more entities after + * they are retrieved, for example by calling [EntityClass.all]. + */ + var maxEntitiesToStore = transaction.db.config.maxEntitiesToStoreInCachePerEntity + set(value) { + val diff = value - field + field = value + if (diff < 0) { + data.values.forEach { it.trimToFirst(value) } + } + } + + /** Stores the specified [Entity] in this cache using its associated [EntityClass] as the key. */ + fun > store(f: EntityClass, o: T) { + getMap(f)[o.id.value] = o + } + + /** + * Stores the specified [Entity] in this cache. + * + * The [EntityClass] associated with this entity is inferred from its [Entity.klass] property. + */ + fun store(o: Entity<*>) { + getMap(o.klass.table)[o.id.value] = o + } + + /** Removes the specified [Entity] from this [EntityCache] using its associated [table] as the key. */ + 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) } + } + + /** Stores the specified [Entity] in this [EntityCache] as scheduled to be updated in the database. */ + fun scheduleUpdate(klass: EntityClass>, entity: Entity) { + updates.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) + } + + /** Gets all [Entity] instances in this [EntityCache] that match the associated [EntityClass]. */ + fun > findAll(entityClass: EntityClass): List { + val map = data[entityClass.table] ?: return emptyList() + return map.values.toList() as List + } + + private val initializingEntities = LinkedIdentityHashSet>() + + internal fun isEntityInInitializationState(entity: Entity): Boolean { + return initializingEntities.contains(entity) + } + + internal fun isScheduledForInsert(entity: Entity): Boolean { + return inserts[entity.klass.table]?.contains(entity) ?: false + } + + internal fun isStoredInData(entity: Entity): Boolean { + val value = entity.id._value ?: return false + return data[entity.klass.table]?.get(value) === entity + } + + internal fun addNotInitializedEntityToQueue(entity: Entity) { + require(initializingEntities.add(entity)) { "Entity ${entity::class.simpleName} already in initialization process" } + } + + internal fun finishEntityInitialization(entity: Entity) { + require(initializingEntities.lastOrNull() == entity) { + "Can't finish initialization for entity ${entity::class.simpleName} - the initialization order is broken" + } + initializingEntities.remove(entity) + } + + /** Stores the specified [Entity] in this [EntityCache] as scheduled to be inserted into the database. */ + fun scheduleInsert(klass: EntityClass>, entity: Entity) { + inserts.getOrPut(klass.table) { LinkedIdentityHashSet() }.add(entity) + } + + /** + * Returns a [SizedIterable] containing all child [Entity] instances that reference the parent entity with + * the provided [sourceId] using the specified [key] column. + * + * If either the [key] column is not present or a value does not exist for the parent entity, the default [refs] + * will be called and its result will be put into the map under the given keys and the call result returned. + */ + suspend fun > getOrPutReferrers( + sourceId: EntityID<*>, + key: Column<*>, + refs: suspend () -> SizedIterable<@UnsafeVariance R> + ): SizedIterable { + val columnReferrers = referrers.getOrPut(key) { ConcurrentHashMap() } + @Suppress("UNCHECKED_CAST") + return columnReferrers.getOrPut(sourceId) { LazySizedCollection(refs()) } as SizedIterable + } + + /** + * Returns a [SizedIterable] containing all child [Entity] instances that reference the parent entity with + * the provided [sourceId] using the specified [key] column. + */ + fun > getReferrers(sourceId: EntityID<*>, key: Column<*>): SizedIterable? { + @Suppress("UNCHECKED_CAST") + return referrers[key]?.get(sourceId) as? SizedIterable + } + + /** + * Clears this [EntityCache] of all stored data, including any reference mappings. + * + * @param flush By default, pending inserts and updates for all cached entities will first be sent to the + * database. If this is set to `false`, any pending operations will not be flushed and will be removed as well. + */ + suspend fun clear(flush: Boolean = true) { + if (flush) flush() + data.clear() + inserts.clear() + updates.clear() + pendingInnerTableLinkUpdates.clear() + clearReferrersCache() + } + + /** Clears this [EntityCache] of stored data that maps cached parent entities to their referencing child entities. */ + fun clearReferrersCache() { + referrers.clear() + } + + private suspend fun updateEntities(table: IdTable) { + val update = updates.remove(table) ?: return + if (update.isEmpty()) return + + val updatedEntities = HashSet>() + val batch = EntityBatchUpdate(update.first().klass) + + for (entity in update) { + if (entity.flush(batch)) { + updatedEntities.add(entity) + } + } + + executeAsPartOfEntityLifecycle { + batch.execute(transaction) + } + + updatedEntities.forEach { + transaction.registerChange(it.klass, it.id, EntityChangeType.Updated) + } + } + + /** Sends all pending inserts and updates for all [Entity] instances in this [EntityCache] to the database. */ + suspend fun flush() { + if (inserts.isEmpty() && updates.isEmpty() && pendingInnerTableLinkUpdates.isEmpty()) return + val toFlush = when { + inserts.isNotEmpty() && updates.isNotEmpty() -> inserts.keys + updates.keys + inserts.isNotEmpty() -> inserts.keys + updates.isNotEmpty() -> updates.keys + else -> emptyList() + } + flush(toFlush) + } + + /** + * Sends all pending inserts and updates for [Entity] instances in this [EntityCache] to the database. + * + * The only entities that will be flushed are those that can be associated with any of the specified [tables]. + */ + suspend fun flush(tables: Iterable>) { + if (flushingEntities) return + try { + flushingEntities = true + val insertedTables = inserts.keys + + val updateBeforeInsert = SchemaUtils.sortTablesByReferences(insertedTables).filterIsInstance>() + updateBeforeInsert.forEach { updateEntities(it) } + + SchemaUtils.sortTablesByReferences(tables).filterIsInstance>().forEach { flushInserts(it) } + + val updateTheRestTables = tables - updateBeforeInsert.toSet() + for (t in updateTheRestTables) { + updateEntities(t) + } + + if (insertedTables.isNotEmpty()) { + removeTablesReferrers(insertedTables, true) + } + + if (pendingInnerTableLinkUpdates.isNotEmpty()) { + executePendingInnerTableLinkUpdates() + } + } finally { + flushingEntities = false + } + } + + private suspend fun executePendingInnerTableLinkUpdates() { + // Flush all remaining inserts/updates first — the deferred link operations + // need entities from arbitrary tables to have IDs. + val remainingInserts = inserts.keys.toList() + for (table in SchemaUtils.sortTablesByReferences(remainingInserts).filterIsInstance>()) { + flushInserts(table) + } + val remainingUpdates = updates.keys.toList() + for (table in remainingUpdates) { + updateEntities(table) + } + + val pending = pendingInnerTableLinkUpdates.toList() + pendingInnerTableLinkUpdates.clear() + for (op in pending) { + op() + } + } + + 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 } } + } + + 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) + } + } + + @Suppress("UNCHECKED_CAST") + internal suspend fun flushInserts(table: IdTable) { + var entitiesToInsert = inserts.remove(table)?.toList().orEmpty() + if (entitiesToInsert.isEmpty()) return + + while (entitiesToInsert.isNotEmpty()) { + val (currentBatch, nextBatch) = partitionEntitiesForInsert(entitiesToInsert, table) + entitiesToInsert = nextBatch + + // Snapshot writeValues before the batchInsert reads them, so we can merge + // client-set values back into `_readValues` for drivers that only return + // generated columns. + val writeValuesSnapshots = currentBatch.map { it.writeValues.toMap() } + + val genRows = table.batchInsert(currentBatch) { entry -> + for ((c, v) in entry.writeValues) { + this[c] = v + } + } + + currentBatch.forEachIndexed { idx, entity -> + val resultRow = genRows[idx] + adoptInsertResult(entity, resultRow, writeValuesSnapshots[idx], table) + } + } + + transaction.alertSubscribers() + } + + private fun partitionEntitiesForInsert( + entities: List>, + table: IdTable<*> + ): Pair>, List>> { + val firstEntityColumns = entities.first().writeValues.keys + return entities.partition { entity -> + val refereeFromSameTableAlreadyCreated = entity.writeValues.none { (key, value) -> + key.referee == table.id && value is EntityID<*> && value._value == null + } + val columnSetAlignedWithFirstEntity = entity.writeValues.keys == firstEntityColumns + refereeFromSameTableAlreadyCreated && columnSetAlignedWithFirstEntity + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun adoptInsertResult( + entity: Entity<*>, + resultRow: ResultRow, + writeValuesSnapshot: Map, Any?>, + table: IdTable + ) { + val entityId = entity.id as EntityID + val generatedId = resultRow[table.id] + if (entityId._value == null) { + entityId._value = generatedId.value + entity.writeIdColumnValue(entity.klass.table, generatedId) + } + entity._readValues = resultRow + + // R2DBC drivers commonly return only generated columns, leaving `_readValues` missing + // client-set values. Merge them in so subsequent reads see a complete row. + val readValues = entity._readValues + if (readValues != null) { + for ((col, value) in writeValuesSnapshot) { + if (!readValues.hasValue(col)) readValues[col] = value + } + } + + // If any table column is still missing (e.g. a database-side `defaultExpression` that the + // INSERT didn't return), re-SELECT the row. + if (table.columns.any { entity._readValues?.hasValue(it) != true }) { + val freshRow = table.selectAll().where { table.id eq entityId }.firstOrNull() + if (freshRow != null) entity._readValues = freshRow + } + + entity.writeValues.clear() + + store(entity) + transaction.registerChange(entity.klass, entity.id, EntityChangeType.Created) + } +} + +/** + * Sends all pending [Entity] inserts and updates stored in this transaction's [EntityCache] to the database. + * + * @return A list of all new entities that were stored as scheduled for insert. + */ +@ExperimentalR2dbcDaoApi +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 `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-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityClass.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityClass.kt new file mode 100644 index 0000000000..5609c02f49 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityClass.kt @@ -0,0 +1,1141 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.flow.toList +import 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.dao.id.IdTable +import org.jetbrains.exposed.v1.dao.r2dbc.exceptions.EntityNotFoundException +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.BackReference +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.OptionalBackReference +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.OptionalReference +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.Reference +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.Referrers +import org.jetbrains.exposed.v1.r2dbc.* +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KFunction +import kotlin.reflect.full.primaryConstructor + +/** + * Base class responsible for the management of [Entity] instances and the maintenance of their relation + * to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities managed by this class. + * @param [entityType] The expected [Entity] class type. This can be left `null` if it is the class of type + * argument [T] provided to this [EntityClass] instance. + * @param [entityCtor] The function invoked to instantiate an [Entity] using a provided [EntityID] value. If a + * reference to a specific entity constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access (which can be slower). + */ +@Suppress("TooManyFunctions") +@ExperimentalR2dbcDaoApi +abstract class EntityClass>( + 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) } + + /** + * Creates a new [Entity] instance with the fields set in the [init] block, schedules its insert, flushes the + * entity cache, and returns the fully-hydrated entity with its auto-generated id and database-generated + * columns populated. + * + * Mirrors JDBC's `EntityClass.new` semantics: after this call the entity's [Entity.id] is immediately usable. + * + * @param init Suspending block where the entity's fields can be set. + * @return The created and flushed entity. + */ + open suspend fun new(init: suspend T.() -> Unit): T = new(null, init) + + /** The [IdTable] that this [EntityClass] depends on when maintaining relations with managed [Entity] instances. */ + open val dependsOnTables: ColumnSet get() = table + + /** The columns that this [EntityClass] depends on when maintaining relations with managed [Entity] instances. */ + open val dependsOnColumns: List> get() = dependsOnTables.columns + + /** + * Creates a new [Entity] instance with the fields set in the [init] block and with the provided [id], + * schedules its insert, flushes the entity cache, and returns the fully-hydrated entity. + * + * @param id The id of the entity. Set this to `null` if it should be automatically generated. + * @param init Suspending block where the entity's fields can be set. + * @return The created and flushed entity. + */ + open suspend fun new(id: ID?, init: suspend T.() -> Unit): T { + val prototype = scheduleNew(id, init) + TransactionManager.current().entityCache.flush() + return prototype + } + + /** + * Creates a new [Entity] instance with the fields set in the [init] block, schedules its insert into the + * entity cache **without** flushing, and returns a cold [Flow] that emits exactly the created entity. + * + * **Discarding the flow does not cancel the insert.** The entity is scheduled when [newDeferred] is + * called, not when the flow is collected, so it is written either way. The pending insert is flushed at + * the first of: + * - collection of the returned flow; + * - any other statement executed in the same transaction — a query flushes the entities of the tables it + * reads, while an insert, update, upsert, delete or DDL statement flushes everything pending; + * - the transaction's commit. + * + * Collecting therefore controls *when* the insert is issued, and is the only way to obtain the hydrated + * entity; it does not control *whether* the row is written. + * + * Intended for batching: scheduling several entities via [newDeferred] and then collecting them together + * results in a single flush covering all pending inserts. + * + * ```kotlin + * val users: List = names + * .map { name -> User.newDeferred { this.name = name } } + * .asFlow() + * .flattenConcat() + * .toList() + * ``` + * + * Use `flattenConcat` rather than `merge` — `merge` gives no ordering guarantee, so the + * collected list would not follow the order the entities were scheduled in. + * + * The flow is bound to the transaction that created it: the pending insert lives in that + * transaction's entity cache, so collecting the flow anywhere else cannot flush it. Rather than + * report a success it did not produce, such a collection throws [IllegalStateException] — by then + * the insert has either already happened at commit time, or, if that transaction rolled back, + * never happened at all. + * + * @param init Block where the entity's fields can be set. Must be non-suspending, since scheduling is + * performed synchronously. + * @return A cold [Flow] that emits the created entity on first collection. + * @throws IllegalStateException on collection, if the current transaction is not the one that + * created the flow, or if there is no transaction in context. + */ + open fun newDeferred(init: T.() -> Unit): Flow = newDeferred(null, init) + + /** + * Composite/explicit-id variant of [newDeferred]. + * + * @param id The id of the entity. Set this to `null` if it should be automatically generated. + * @param init Block where the entity's fields can be set. Must be non-suspending. + * @return A cold [Flow] that emits the created entity on first collection. + * @throws IllegalStateException on collection, if the current transaction is not the one that + * created the flow, or if there is no transaction in context. + */ + open fun newDeferred(id: ID?, init: T.() -> Unit): Flow { + val prototype = scheduleNewSync(id, init) + // Only the id is captured, never the transaction itself: the flow may outlive the transaction, + // and holding it would keep its connection and entity cache reachable. + val schedulingTransactionId = TransactionManager.current().transactionId + return flow { + val transaction = TransactionManager.currentOrNull() + ?: error( + "The flow returned by newDeferred() must be collected inside the transaction that created " + + "the entity, but no transaction is in context. The insert was scheduled in the entity " + + "cache of transaction $schedulingTransactionId and is unreachable from here." + ) + check(transaction.transactionId == schedulingTransactionId) { + "The flow returned by newDeferred() must be collected inside the transaction that created the " + + "entity. The insert was scheduled in transaction $schedulingTransactionId but collection " + + "happened in transaction ${transaction.transactionId}, whose entity cache knows nothing " + + "about it." + } + transaction.entityCache.flush() + emit(prototype) + } + } + + private suspend fun scheduleNew(id: ID?, init: suspend T.() -> Unit): T { + val prototype = createPrototype(id) + val entityCache = warmCache() + try { + entityCache.addNotInitializedEntityToQueue(prototype) + prototype.init() + } finally { + entityCache.finishEntityInitialization(prototype) + } + applyColumnDefaults(prototype) + @Suppress("UNCHECKED_CAST") + entityCache.scheduleInsert(this as EntityClass>, prototype as Entity) + return prototype + } + + private fun scheduleNewSync(id: ID?, init: T.() -> Unit): T { + val prototype = createPrototype(id) + val entityCache = warmCache() + try { + entityCache.addNotInitializedEntityToQueue(prototype) + prototype.init() + } finally { + entityCache.finishEntityInitialization(prototype) + } + applyColumnDefaults(prototype) + @Suppress("UNCHECKED_CAST") + entityCache.scheduleInsert(this as EntityClass>, prototype as Entity) + return prototype + } + + private fun createPrototype(id: ID?): T { + val entityId = if (id == null && table.id.defaultValueFun != null) { + table.id.defaultValueFun!!() + } else { + DaoEntityID(id, table) + } + 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) + } + return prototype + } + + private fun applyColumnDefaults(prototype: T) { + 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] + } + } + + /** + * Instantiates an [EntityCache] with the current [R2dbcTransaction] if one does not already exist in the + * current transaction scope. + */ + protected open fun warmCache(): EntityCache = TransactionManager.current().entityCache + + /** Creates a new [Entity] instance with the provided [entityId] value. */ + protected open fun createInstance(entityId: EntityID, row: ResultRow?): T = entityCtor(entityId) + + /** + * Gets an [Entity] by its raw [id] value. Mirrors JDBC's `EntityClass.findById(ID)` — + * wraps the value in an [EntityID] (or [CompositeID]-backed [DaoEntityID]) before + * delegating to the [EntityID][findById] overload below. + */ + suspend fun findById(id: ID): T? = findById(DaoEntityID(id, table)) + + /** + * Gets an [Entity] by its [EntityID] value. + * + * @param id The [EntityID] value of the entity. + * @return The entity that has this wrapped id value, or `null` if no entity was found. + */ + open suspend fun findById(id: EntityID): T? { + val cached = testCache(id) + if (cached != null) return cached + + return find { table.id eq id }.firstOrNull() + } + + /** + * Gets an [Entity] by its [id] value and updates the retrieved entity. + * + * @param id The id value of the entity. + * @param block Lambda that contains entity field updates. + * @return The updated entity that has this id value, or `null` if no entity was found. + */ + suspend fun findByIdAndUpdate(id: ID, block: (it: T) -> Unit): T? { + val result = find(table.id eq DaoEntityID(id, table)).forUpdate().firstOrNull() ?: return null + block(result) + return result + } + + /** + * Gets a single [Entity] that conforms to the [op] conditional expression and updates the retrieved entity. + * + * @param op The conditional expression to use when selecting the entity. + * @param block Lambda that contains entity field updates. + * @return The updated entity that conforms to this condition, or `null` if either no entity was found + * or if more than one entity conforms to the condition. + */ + suspend fun findSingleByAndUpdate(op: Op, block: (it: T) -> Unit): T? { + val result = find(op).forUpdate().singleOrNull() ?: return null + block(result) + return result + } + + /** + * Searches the current [EntityCache] for an [Entity] by its [EntityID] value. + * + * @return The entity that has this wrapped id value, or `null` if no entity was found. + */ + fun testCache(id: EntityID): T? = warmCache().find(this, id) + + /** + * Reloads the fields of an [entity] from the database and returns the [entity] as a new object. + * + * The original [entity] will also be removed from the current cache. + * @see removeFromCache + * + * @param flush Whether pending entity changes should be flushed prior to reloading. + */ + suspend fun reload(entity: Entity, 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 + } + + /** Named after JDBC's counterpart, but only verifies — reloading the row would have to suspend. */ + internal open fun invalidateEntityInCache(o: Entity) { + val sameDatabase = TransactionManager.current().db == o.db + if (!sameDatabase) return + + val cache = warmCache() + + if (cache.isEntityInInitializationState(o)) return + if (cache.isScheduledForInsert(o)) return + if (cache.isStoredInData(o)) return + + // Not in any tracked state. Either the entity was deleted in this transaction, + // or it was loaded in a different transaction and has not been `attach`-ed here. + // + // 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 EntityNotFoundException(o.id, this) + } + + /** + * R2DBC-specific helper. Registers an [entity] from a previous transaction in the current transaction's cache, + * allowing it to be read and modified in the new transaction. + * + * In JDBC DAO, `Column.setValue` can synchronously query the database and implicitly adopt + * an entity from another transaction. R2DBC's `setValue` is non-suspend, so this must be + * done explicitly before mutating the entity. + * + * **Behavior:** + * - If [entity] is already the instance this transaction tracks, does nothing. + * - If nothing is tracked for that id, verifies that the row still exists in the database + * (throws [EntityNotFoundException] if not) and stores [entity]. + * - If a *different* instance of the same row is tracked, [entity] replaces it, so that subsequent + * property reads and writes go through the caller's instance. Two instances of one row arise + * routinely — for example when this transaction also loads the row itself — and the replaced + * instance is only a cache entry, so discarding it is harmless. Unless it has unflushed changes, + * in which case replacing it would silently drop them and this throws instead; pass [force] to + * discard them deliberately. + * + * **Flush behavior:** + * After attaching and modifying an entity, there is no need to call `flush()` explicitly — + * pending `writeValues` are auto-flushed by `EntityLifecycleInterceptor.beforeCommit` + * as part of the transaction's commit. This is consistent with JDBC behavior. + * + * **Typical usage:** + * ```kotlin + * val entity = suspendTransaction { MyEntity.new { name = "foo" }.flush() } + * suspendTransaction { + * MyEntity.attach(entity) // register in new transaction's cache + * entity.name = "bar" // now safe to modify + * } + * ``` + * + * @param entity The entity to track in the current transaction. + * @param force Replace a tracked instance even when it holds unflushed changes, discarding them. + * @throws EntityNotFoundException if the row no longer exists in the database. + * @throws IllegalStateException if another instance of the same row is tracked with unflushed + * changes and [force] is `false`. + */ + suspend fun attach(entity: Entity, force: Boolean = false) { + val cache = warmCache() + val tracked = cache.find(this, entity.id) + + if (tracked === entity) return + + if (tracked == null) { + // Verify the row still exists — also stores a fresh instance in the cache as a side effect, + // which the store below then overwrites with the caller's reference. + findById(entity.id) ?: throw EntityNotFoundException(entity.id, this) + } else if (tracked.writeValues.isNotEmpty() && !force) { + error( + "Another instance of ${entity.id} is already tracked in this transaction and has unflushed " + + "changes. Attaching would discard them — flush that instance first, or pass `force = true` " + + "to overwrite it." + ) + } + + cache.store(entity) + } + + /** Gets all the [Entity] instances associated with this [EntityClass]. */ + open fun all(): SizedIterable = wrapRows(table.selectAll().notForUpdate()) + + /** + * Gets all the [Entity] instances that conform to the [op] conditional expression. + * + * @param op The conditional expression to use when selecting the entity. + * @return A [SizedIterable] of all the entities that conform to this condition. + */ + fun find(op: Op): SizedIterable { + warmCache() + return wrapRows(searchQuery(op)) + } + + /** + * Gets all the [Entity] instances that conform to the [op] conditional expression. + * + * @param op The conditional expression to use when selecting the entity. + * @return A [SizedIterable] of all the entities that conform to this condition. + */ + fun find(op: () -> Op): SizedIterable = find(op()) + + /** + * Returns a [Query] to select all columns in [dependsOnTables] with a WHERE clause that includes + * the provided [op] conditional expression. + */ + open fun searchQuery(op: Op): Query = + dependsOnTables.select(dependsOnColumns).where { op }.notForUpdate() + + /** + * Returns a [SizedIterable] containing entities generated using data retrieved from a database result set in [rows]. + */ + fun wrapRows(rows: SizedIterable): SizedIterable = rows mapLazy { + wrapRow(it) + } + + /** + * Wraps the specified [ResultRow] data into an [Entity] 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 + } + + /** + * Wraps the specified [ResultRow] data into an [Entity] instance. + * + * The provided [alias] will be used to adjust the [ResultRow] mapping before returning the 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))) + } + + /** + * Wraps the specified [ResultRow] data into an [Entity] instance. + * + * The provided [alias] will be used to adjust the [ResultRow] mapping before returning the entity. + */ + 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))) + } + + /** + * Returns an [Entity] with the provided [EntityID] value, or, if an entity was not found in the current + * [EntityCache], creates a new instance using the data in [row]. + */ + 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) + } + } + + /** + * Gets an [Entity] by its [EntityID] value. + * + * @throws EntityNotFoundException if no entity was found. + */ + suspend operator fun get(id: EntityID): T = findById(id) ?: throw EntityNotFoundException(id, this) + + /** + * Gets an [Entity] by its raw [id] value. + * + * @throws EntityNotFoundException if no entity was found. + */ + suspend operator fun get(id: ID): T = get(DaoEntityID(id, table)) + + /** + * Removes the specified [entity] from the current [EntityCache], as well as any stored references to + * or from the removed entity. + */ + fun removeFromCache(entity: Entity) { + val cache = warmCache() + cache.remove(table, entity) + // 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) + + // Remove references from other entities to this entity + if (col.table == table) { + with(entity) { col.lookup() }?.let { referrers.remove(it as EntityID<*>) } + } + } + } + + /** + * Registers a reference as a field of the child entity class, which returns a parent object of this `EntityClass`. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + * + * R2DBC counterpart of JDBC's `referencedOn`. Returns a [Reference] that delegates to a suspending + * [org.jetbrains.exposed.v1.dao.r2dbc.relationships.Accessor] — JDBC's version returns the entity directly via a synchronous lookup. + */ + infix fun referencedOn(column: Column): Reference = + Reference(column, this) + + /** + * Composite-FK form of [referencedOn]. 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. + */ + @Suppress("UNCHECKED_CAST") + infix fun referencedOn(table: IdTable<*>): Reference { + val tableFK = getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return Reference(delegate, this, references = tableFK.references) + } + + /** + * Registers an optional reference as a field of the child entity class, which returns a parent object of + * this `EntityClass`. + * + * The reference should have been defined by the creation of a [column] using either `optReference()` or + * `reference().nullable()` on the child table. + * + * R2DBC counterpart of JDBC's `optionalReferencedOn`. + */ + infix fun optionalReferencedOn(column: Column): OptionalReference = + OptionalReference(column, this) + + /** + * Composite-FK form of [optionalReferencedOn]. R2DBC counterpart of JDBC's + * `optionalReferencedOn(IdTable<*>)`. + */ + @Suppress("UNCHECKED_CAST") + infix fun optionalReferencedOn(table: IdTable<*>): OptionalReference { + val tableFK = getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return OptionalReference(delegate, this, references = tableFK.references) + } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a [column] using either `optReference()` or + * `reference().nullable()` on the child table. + * + * By default, this also stores the loaded entities to a cache. + */ + infix fun , REF : Any> + EntityClass.optionalReferrersOn( + column: Column + ): Referrers, TargetID, Target, REF?> = + Referrers(column, this, cache = true) + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a [column] using either `optReference()` or + * `reference().nullable()` on the child table. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun , REF : Any> + EntityClass.optionalReferrersOn( + column: Column, + cache: Boolean + ): Referrers, TargetID, Target, REF?> = + Referrers(column, this, cache) + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a child object of + * this `EntityClass`. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + */ + infix fun , REF> + EntityClass.backReferencedOn( + column: Column + ): BackReference, REF> = + BackReference(column, this) + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a child object of + * this `EntityClass` or `null` if no child references the parent entity. + * + * The reference could have been defined on the child table in 1 of the following ways: + * - By the creation of a [column] using either `optReference()` or `reference().nullable()` + * - By the creation of a non-nullable `reference()` [column] where either 0 or 1 row(s) is expected in the relationship + */ + infix fun , REF> + EntityClass.optionalBackReferencedOn( + column: Column + ): OptionalBackReference, REF> = + OptionalBackReference(column, this) + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a child object of + * this `EntityClass` or `null` if no child references the parent entity. + * + * Overload of [optionalBackReferencedOn] for non-nullable reference columns — mirrors JDBC's + * overloaded `optionalBackReferencedOn(Column)`. + */ + @Suppress("UNCHECKED_CAST") + @JvmName("optionalBackReferencedOnNonNullable") + infix fun , REF : Any> + EntityClass.optionalBackReferencedOn( + column: Column + ): OptionalBackReference, REF> = + OptionalBackReference(column as Column, this) + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + * + * By default, this also stores the loaded entities to a cache. + */ + @Suppress("UNCHECKED_CAST") + infix fun , REF> EntityClass.referrersOn( + column: Column + ): Referrers, TargetID, Target, REF> = + Referrers(column, this, cache = true) + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun , REF> + EntityClass.referrersOn( + column: Column, + cache: Boolean + ): Referrers, TargetID, Target, REF> = + Referrers(column, this, cache) + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + */ + @Suppress("UNCHECKED_CAST") + infix fun > + EntityClass.referrersOn( + table: IdTable<*> + ): Referrers, TargetID, Target, Any> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return Referrers(delegate, this, cache = true, references = tableFK.references) + } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + * + * By default, this also stores the loaded entities to a cache. + */ + @Suppress("UNCHECKED_CAST") + infix fun > + EntityClass.optionalReferrersOn( + table: IdTable<*> + ): Referrers, TargetID, Target, Any?> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return Referrers(delegate, this, cache = true, references = tableFK.references) + } + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a child object of + * this `EntityClass`. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + */ + @Suppress("UNCHECKED_CAST") + infix fun > + EntityClass.backReferencedOn( + table: IdTable<*> + ): BackReference, Any> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return BackReference(delegate, this, references = tableFK.references) + } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a child object of + * this `EntityClass` or `null` if no child references the parent entity. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + */ + @Suppress("UNCHECKED_CAST") + infix fun > + EntityClass.optionalBackReferencedOn( + table: IdTable<*> + ): OptionalBackReference, Any> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return OptionalBackReference(delegate, this, references = tableFK.references) + } + + /** + * Returns the child table's [ForeignKeyConstraint] that matches the primary key columns defined on the table + * associated with this [EntityClass]. 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." + ) + + /** + * Returns a list of retrieved [Entity] instances whose reference column matches any of the [EntityID] values + * in [references]. Both the entity's source and target reference columns should have been defined in [linkTable]. + * + * The [EntityCache] in the current transaction scope will be searched for matching entities. + * + * Set [forUpdate] to `true` or `false` depending on whether a locking read should be placed or removed from the + * search query used. Leave the argument as `null` to use the query without any locking option. + * + * Set [optimizedLoad] to `true` to force two queries separately, one for loading ids and another for loading + * referenced entities. This could be useful when references target the same entities. This will prevent them from + * loading multiple times (per each reference row) and will require less memory/bandwidth for "heavy" entities + * (with a lot of columns and/or columns that store large data sizes). + */ + @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") + internal 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(it, sourceRefColumn) { + SizedCollection(groupedBySourceId[it] ?: emptyList()) + } + } + targetEntities.values + } + + return inCache.values.flatMap { it.toList() as List } + loaded.orEmpty() + } + + /** Returns a [SizedIterable] containing all entities with [EntityID] values from the provided [ids] list. */ + 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 a [SizedIterable] containing all entities with id values from the provided [ids] list. */ + fun forIds(ids: List): SizedIterable = forEntityIds(ids.map { DaoEntityID(it, table) }) + + /** + * 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 + ) + + /** + * Counts the amount of [Entity] instances that conform to the [op] conditional expression. + * + * @param op The conditional expression to use when selecting the entity. + * @return The amount of entities that conform to this condition. + */ + 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 a list of retrieved [Entity] instances whose [refColumn] matches any of the id values in [references]. + * + * The [EntityCache] in the current transaction scope will be searched for matching entities, if appropriate + * for [refColumn]'s column type; otherwise, matching results will be queried from the database. + * + * Set [orderBy] to specify the order in which entities should be sorted. + */ + @Suppress("UNCHECKED_CAST") + suspend fun warmUpReferences( + references: List, + refColumn: Column, + orderBy: Array, SortOrder>>? = null + ): List { + val parentTable = refColumn.referee?.table as? IdTable<*> + requireNotNull(parentTable) { "RefColumn should have reference to IdTable" } + if (references.isEmpty()) return emptyList() + val distinctRefIds = references.distinct() + val transaction = TransactionManager.current() + val cache = transaction.entityCache + val keepLoadedReferenceOutOfTransaction = transaction.db.config.keepLoadedReferencesOutOfTransaction + if (refColumn.columnType is EntityIDColumnType<*>) { + refColumn as Column> + distinctRefIds as List> + val toLoad: List> = distinctRefIds.filter { + cache.referrers[refColumn]?.containsKey(it)?.not() ?: true + } + if (toLoad.isNotEmpty()) { + val entities = find { refColumn inList toLoad } + .orderBy(order = orderBy ?: emptyArray()) + .toList() + + val result = entities.groupByReference(refColumn = refColumn) + + distinctRefIds.forEach { id -> + cache.getOrPutReferrers(id, refColumn) { + result[id]?.let { SizedCollection(it) } ?: SizedCollection(emptyList()) + }.also { + if (keepLoadedReferenceOutOfTransaction) { + cache.find(this, id as EntityID)?.storeReferenceInCache(refColumn, it) + } + } + } + } + + return distinctRefIds.flatMap { cache.getReferrers(it, refColumn)?.toList().orEmpty() } + } else { + val baseQuery = searchQuery(refColumn inList distinctRefIds) + val finalQuery = if (parentTable.id in baseQuery.set.fields) { + baseQuery + } else { + baseQuery.adjustSelect { select(fields + parentTable.id) } + .adjustColumnSet { innerJoin(parentTable, { refColumn }, { refColumn.referee!! }) } + } + .orderBy(order = orderBy ?: emptyArray()) + + val entities = wrapRows(finalQuery).toList().distinct() + + entities.groupByReference(refColumn = refColumn).forEach { (id, values) -> + val castReferee = refColumn.referee + .takeUnless { it?.columnType is EntityIDColumnType<*> && id !is EntityID<*> } + ?: (refColumn.referee?.columnType as EntityIDColumnType<*>).idColumn + val parentEntityId: EntityID<*> = parentTable.selectAll().where { castReferee as Column eq id } + .first()[parentTable.id] + + cache.getOrPutReferrers(parentEntityId, refColumn) { SizedCollection(values) }.also { + if (keepLoadedReferenceOutOfTransaction) { + val childEntity = find { refColumn eq id }.firstOrNull() + childEntity?.storeReferenceInCache(refColumn, it) + } + } + } + return entities + } + } + + /** + * Returns a list of retrieved [Entity] instances whose [refColumn] optionally matches any of the id values in [references]. + * + * The [EntityCache] in the current transaction scope will be searched for matching entities, if appropriate + * for [refColumn]'s column type; otherwise, matching results will be queried from the database. + * + * Set [orderBy] to specify the order in which entities should be sorted. + */ + suspend fun warmUpOptReferences( + references: List, + refColumn: Column, + orderBy: Array, SortOrder>>? = null + ): List { + @Suppress("UNCHECKED_CAST") + return warmUpReferences(references, refColumn as Column, orderBy) + } + + @Suppress("UNCHECKED_CAST") + internal suspend fun warmUpCompositeIdReferences( + references: List, + refColumns: Map, Column<*>>, + delegateRefColumn: Column<*>, + orderBy: Array, SortOrder>>? = null + ): List { + val parentTable = refColumns.values.firstOrNull()?.table as? CompositeIdTable + requireNotNull(parentTable) { "RefColumns should have reference to CompositeIdTable" } + if (references.isEmpty()) return emptyList() + val distinctRefIds = references.distinct().map { EntityID(it, parentTable) } + val transaction = TransactionManager.current() + val cache = transaction.entityCache + val keepLoadedReferenceOutOfTransaction = transaction.db.config.keepLoadedReferencesOutOfTransaction + if (refColumns.keys.all { it.columnType is EntityIDColumnType<*> }) { + val toLoad = distinctRefIds.filter { + cache.referrers[delegateRefColumn]?.containsKey(it)?.not() ?: true + } + if (toLoad.isNotEmpty()) { + val entities = find { refColumns.keys.toList() inList toLoad.map { it.value } } + .orderBy(order = orderBy ?: emptyArray()) + .toList() + val result = entities.groupByReference(refColumns = refColumns) + + distinctRefIds.forEach { id -> + cache.getOrPutReferrers(id, delegateRefColumn) { + result[id.value]?.let { SizedCollection(it) } ?: SizedCollection(emptyList()) + }.also { + if (keepLoadedReferenceOutOfTransaction) { + cache.find(this, id as EntityID)?.storeReferenceInCache(delegateRefColumn, it) + } + } + } + } + + return distinctRefIds.flatMap { cache.getReferrers(it, delegateRefColumn)?.toList().orEmpty() } + } else { + val baseQuery = searchQuery(refColumns.keys.toList() inList distinctRefIds.map { it.value }) + .orderBy(order = orderBy ?: emptyArray()) + val entities = wrapRows(baseQuery).toList().distinct() + val result = entities.groupByReference(refColumns = refColumns) + + result.forEach { (id, values) -> + val parentEntityId: EntityID<*> = parentTable.selectAll().where { parentTable.id eq id } + .first()[parentTable.id] + + cache.getOrPutReferrers(parentEntityId, delegateRefColumn) { SizedCollection(values) }.also { + if (keepLoadedReferenceOutOfTransaction) { + val childEntity = find { refColumns.keys.toList() inList listOf(id) }.firstOrNull() + childEntity?.storeReferenceInCache(delegateRefColumn, it) + } + } + } + return entities + } + } + + private fun List.groupByReference(refColumn: Column): Map> = + groupBy { it.readValues[refColumn] } + + @Suppress("UNCHECKED_CAST") + private fun List.groupByReference(refColumns: Map, Column<*>>): Map> = + groupBy { entity -> + getCompositeID { + refColumns.map { (child, parent) -> parent to entity.readValues[child] } + } as R + } + + /** + * Returns whether the [entityClass] type is equivalent to or a superclass of this [EntityClass] instance's [klass]. + * Mirrors JDBC's `EntityClass.isAssignableTo`. + */ + fun > isAssignableTo(entityClass: EntityClass) = + entityClass.klass.isAssignableFrom(klass) +} + +internal fun hasSingleReferenceWithReferee(allReferences: Map, Column<*>>?): Boolean { + return allReferences?.size == 1 && allReferences.values.first().table !is CompositeIdTable +} + +@Suppress("UNCHECKED_CAST") +internal fun getCompositeID(entries: () -> List, *>>): CompositeID = CompositeID { + entries().forEach { (key, value) -> + it[key as Column>] = value as Any + } +} + +/** + * Unwraps any [ColumnWithTransform] values down to the underlying column type. Used by + * [EntityClass.wrapRow]'s selective-merge path so transformed columns aren't re-wrapped + * when their values are re-stored into [Entity._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/v1/dao/r2dbc/EntityFieldWithTransform.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform.kt new file mode 100644 index 0000000000..84f00769f3 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityFieldWithTransform.kt @@ -0,0 +1,39 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnTransformer + +/** + * Class responsible for enabling [Entity] field transformations, which may be useful when advanced database + * type conversions are necessary for entity mappings. + */ +@ExperimentalR2dbcDaoApi +open class EntityFieldWithTransform( + /** The original column that will be transformed */ + val column: Column, + /** Instance of [ColumnTransformer] with the transformation logic */ + private val transformer: ColumnTransformer, + /** + * The function used to convert a transformed value to a value that can be stored in the original column type. + * 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/v1/dao/r2dbc/EntityHook.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityHook.kt new file mode 100644 index 0000000000..046fde0fcb --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityHook.kt @@ -0,0 +1,137 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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 + +/** Represents the possible states of an [Entity] throughout its lifecycle. */ +@ExperimentalR2dbcDaoApi +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 [Entity] instance. */ +@ExperimentalR2dbcDaoApi +data class EntityChange( + /** The [EntityClass] of the changed entity instance. */ + val entityClass: EntityClass<*, Entity<*>>, + /** 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 [Entity] instance associated with [this][EntityChange] event, + * or `null` if the entity is not found. + */ +@Suppress("UNCHECKED_CAST") +@ExperimentalR2dbcDaoApi +suspend fun > EntityChange.toEntity(): T? = + (entityClass as EntityClass).findById(entityId as EntityID) + +/** + * Returns the actual [Entity] 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. + */ +@ExperimentalR2dbcDaoApi +suspend fun > EntityChange.toEntity(klass: EntityClass): 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>() + +/** + * Class responsible for providing functions that expose [EntityChange] state logic and entity lifecycle features + * for alerting triggers or customizing additional functionality. Subscribers are `suspend` because the + * R2DBC lifecycle runs inside coroutines. + */ +@ExperimentalR2dbcDaoApi +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. */ +@ExperimentalR2dbcDaoApi +fun R2dbcTransaction.registerChange( + entityClass: EntityClass<*, Entity<*>>, + 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]. + */ +@ExperimentalR2dbcDaoApi +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]. */ +@ExperimentalR2dbcDaoApi +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. + */ +@ExperimentalR2dbcDaoApi +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/v1/dao/r2dbc/EntityLifecycleInterceptor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityLifecycleInterceptor.kt new file mode 100644 index 0000000000..ee051865fc --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/EntityLifecycleInterceptor.kt @@ -0,0 +1,143 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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.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 + } +} + +/** + * Represents a [GlobalSuspendStatementInterceptor] specifically responsible for the statement lifecycle of + * [Entity] instances, which is loaded whenever an [R2dbcTransaction] instance is initialized. + */ +@ExperimentalR2dbcDaoApi +class EntityLifecycleInterceptor : GlobalSuspendStatementInterceptor { + + override fun keepUserDataInTransactionStoreOnCommit(userData: Map, Any?>): Map, Any?> { + return userData.filterValues { it is EntityCache } + } + + @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(), false) + if (!isExecutedWithinEntityLifecycle) { + statement.targets.filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } + } + + is UpsertStatement<*>, is BatchUpsertStatement -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(statement.targets, true) + if (!isExecutedWithinEntityLifecycle) { + statement.targets.filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } + } + + is InsertStatement<*> -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(listOf(statement.table), true) + } + + is BatchUpdateStatement -> { + } + + is UpdateStatement -> { + transaction.flushCache() + transaction.entityCache.removeTablesReferrers(statement.targetsSet.targetTables(), false) + if (!isExecutedWithinEntityLifecycle) { + statement.targets.filterIsInstance>().forEach { + transaction.entityCache.data[it]?.clear() + } + } + } + + else -> { + if (statement.type.group == StatementGroup.DDL) transaction.flushCache() + } + } + } + + override suspend fun afterExecution( + transaction: R2dbcTransaction, + contexts: List, + executedStatement: R2dbcPreparedStatementApi + ) { + if (!isExecutedWithinEntityLifecycle || contexts.first().statement !is InsertStatement<*>) { + transaction.alertSubscribers() + } + } + + override suspend fun beforeCommit(transaction: R2dbcTransaction) { + transaction.flushCache() + 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 + entityCache.clearReferrersCache() + + // Clear writeValues and readValues before clearing the cache, so stale data cannot be + // carried over into a new transaction. Both are cleared even though writeValues should not + // have reached readValues at this point. + // + // TODO ALIGN_WITH_JDBC: when ImmutableCachedEntityClass is ported, preserve its _readValues here. + 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) + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ExperimentalR2dbcDaoApi.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ExperimentalR2dbcDaoApi.kt new file mode 100644 index 0000000000..97d4a5df72 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ExperimentalR2dbcDaoApi.kt @@ -0,0 +1,25 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +/** + * API marked with this annotation is experimental. + * The shape of the R2DBC DAO API may change in incompatible ways while it stabilizes. + * + * Opt in either by annotating the call site with + * `@OptIn(org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi::class)` / + * `@org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi`, + * or by adding `-opt-in=org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi` to your Kotlin + * compiler options. + */ +@RequiresOptIn( + message = "This is an experimental Exposed R2DBC DAO API. Its shape may change in incompatible ways. " + + "Opt in with '@OptIn(org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi::class)' " + + "or '@org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi'." +) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS +) +annotation class ExperimentalR2dbcDaoApi diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/IntEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/IntEntity.kt new file mode 100644 index 0000000000..4289229277 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/IntEntity.kt @@ -0,0 +1,29 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped `Int` value. */ +@ExperimentalR2dbcDaoApi +abstract class IntEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [IntEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [IntEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [IntEntityClass] instance. If this `IntEntityClass` is defined as a companion + * object of a custom `IntEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate an [IntEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `IntEntityClass` + * is defined as a companion object of a custom `IntEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class IntEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/LinkedIdentityHashSet.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/LinkedIdentityHashSet.kt new file mode 100644 index 0000000000..9b9da7211b --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/LinkedIdentityHashSet.kt @@ -0,0 +1,88 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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 } + 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) removeFromListByIdentity(element) } + } + + override fun removeAll(elements: Collection): Boolean { + var changed = false + for (e in elements) if (remove(e)) changed = true + return changed + } + + override fun retainAll(elements: Collection): Boolean { + val toKeep: MutableSet = Collections.newSetFromMap(IdentityHashMap()) + toKeep.addAll(elements) + val toRemove = list.filter { it !in toKeep } + if (toRemove.isEmpty()) return false + for (e in toRemove) remove(e) + return true + } + + private fun removeFromListByIdentity(element: T) { + val iter = list.iterator() + while (iter.hasNext()) { + if (iter.next() === element) { + iter.remove() + return + } + } + } + + 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/v1/dao/r2dbc/LongEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/LongEntity.kt new file mode 100644 index 0000000000..01ec6f0a79 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/LongEntity.kt @@ -0,0 +1,29 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped `Long` value. */ +@ExperimentalR2dbcDaoApi +abstract class LongEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [LongEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [LongEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [LongEntityClass] instance. If this `LongEntityClass` is defined as a companion + * object of a custom `LongEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate a [LongEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `LongEntityClass` + * is defined as a companion object of a custom `LongEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class LongEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UIntEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UIntEntity.kt new file mode 100644 index 0000000000..760286f497 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UIntEntity.kt @@ -0,0 +1,29 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped `UInt` value. */ +@ExperimentalR2dbcDaoApi +abstract class UIntEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [UIntEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [UIntEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [UIntEntityClass] instance. If this `UIntEntityClass` is defined as a companion + * object of a custom `UIntEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate an [UIntEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `UIntEntityClass` + * is defined as a companion object of a custom `UIntEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class UIntEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ULongEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ULongEntity.kt new file mode 100644 index 0000000000..d2a67e5e2e --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/ULongEntity.kt @@ -0,0 +1,29 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped `ULong` value. */ +@ExperimentalR2dbcDaoApi +abstract class ULongEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [ULongEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [ULongEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [ULongEntityClass] instance. If this `ULongEntityClass` is defined as a companion + * object of a custom `ULongEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate a [ULongEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `ULongEntityClass` + * is defined as a companion object of a custom `ULongEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class ULongEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UuidEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UuidEntity.kt new file mode 100644 index 0000000000..b1f527366e --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/UuidEntity.kt @@ -0,0 +1,33 @@ +package org.jetbrains.exposed.v1.dao.r2dbc + +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 + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped [kotlin.uuid.Uuid] value. */ +@OptIn(ExperimentalUuidApi::class) +@ExperimentalR2dbcDaoApi +abstract class UuidEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [UuidEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [UuidEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [UuidEntityClass] instance. If this `UuidEntityClass` is defined as a companion + * object of a custom `UuidEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate a [UuidEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `UuidEntityClass` + * is defined as a companion object of a custom `UuidEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@OptIn(ExperimentalUuidApi::class) +@ExperimentalR2dbcDaoApi +abstract class UuidEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/exceptions/EntityNotFoundException.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/exceptions/EntityNotFoundException.kt new file mode 100644 index 0000000000..39f78e41df --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/exceptions/EntityNotFoundException.kt @@ -0,0 +1,13 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.exceptions + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi + +/** + * An exception that provides information about an [entity] that could not be accessed + * either within the scope of the current entity cache or as a result of a database search error. + */ +@ExperimentalR2dbcDaoApi +class EntityNotFoundException(val id: EntityID<*>, val entity: EntityClass<*, *>) : + 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/v1/dao/r2dbc/java/UUIDEntity.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/java/UUIDEntity.kt new file mode 100644 index 0000000000..96bb8f5626 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/java/UUIDEntity.kt @@ -0,0 +1,33 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.java + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import java.util.UUID + +/** Base class for an [Entity] instance identified by an [id] comprised of a wrapped [java.util.UUID] value. */ +@ExperimentalR2dbcDaoApi +abstract class UUIDEntity(id: EntityID) : Entity(id) + +/** + * Base class representing the [EntityClass] that manages [UUIDEntity] instances and + * maintains their relation to the provided [table]. + * + * @param [table] The [IdTable] object that stores rows mapped to entities of this class. + * @param [entityType] The expected [UUIDEntity] type. This can be left `null` if it is the class of type + * argument [E] provided to this [UUIDEntityClass] instance. If this `UUIDEntityClass` is defined as a companion + * object of a custom `UUIDEntity` class, the parameter will be set to this immediately enclosing class by default. + * @param [entityCtor] The function invoked to instantiate a [UUIDEntity] using a provided [EntityID] value. + * If a reference to a specific constructor or a custom function is not passed as an argument, reflection will + * be used to determine the primary constructor of the associated entity class on first access. If this `UUIDEntityClass` + * is defined as a companion object of a custom `UUIDEntity` class, the constructor will be set to that of the + * immediately enclosing class by default. + */ +@ExperimentalR2dbcDaoApi +abstract class UUIDEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Accessor.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Accessor.kt new file mode 100644 index 0000000000..cfaf60a017 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Accessor.kt @@ -0,0 +1,315 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +import kotlinx.coroutines.flow.singleOrNull +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 +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.r2dbc.transactions.TransactionManager +import kotlin.reflect.KProperty + +/** + * R2DBC accessor returned for non-nullable many-to-one references created via `referencedOn`. + * + * This is an R2DBC-specific shape that has no exact JDBC counterpart: in JDBC, the property delegate's + * `getValue` directly returns the parent entity, whereas R2DBC needs a suspending lookup. The accessor + * is exposed as a `val` and supports two usage patterns: + * + * - `entity.ref()` — suspending read via [invoke]. + * - `entity.ref.set(parent)` — write. + * + * This sidesteps Kotlin's delegation protocol constraint that `getValue` and `setValue` must agree on + * the property type — which would require `setValue` to itself be a `suspend operator`, which Kotlin + * does not support. + */ +@ExperimentalR2dbcDaoApi +class Accessor, REF : Any>( + internal val reference: Column, + internal val factory: EntityClass, + internal val entity: Entity<*>, + /** + * 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 +) { + /** Property delegate operator — returns this accessor itself, used to expose [invoke] / [set]. */ + operator fun getValue(thisRef: Any?, property: KProperty<*>): Accessor { + return this + } + + /** Writes the link to [value] into the underlying [reference] column(s) and pins it in the entity's reference cache. */ + fun set(value: Parent) { + entity.requireTrackedByCurrentTransaction() + + if (entity.db != value.db) { + error("Cannot link entities from different databases") + } + + 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.resolveColumnValue(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 + } + + 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 EntityClass>, entity as Entity) + } + } + + entity.storeReferenceInCache(reference, value) + } + + /** + * Suspending read of the referenced parent entity. Looks up the entity from the cache, or otherwise + * resolves the parent by querying the database via [factory]. + * + * @throws IllegalStateException if the reference value cannot be resolved to a parent entity. + */ + suspend operator fun invoke(): Parent { + if (entity.hasInReferenceCache(reference)) { + return entity.getReferenceFromCache(reference) + } + + if (references != null) { + // Build a CompositeID from the child's columns mapped to the parent's referee columns, + // then `findById`. Mirrors JDBC's composite branch in `Reference.getValue`. + val parentEntity = lookupCompositeParent(factory, entity, references) + ?: error("Referenced entity not found for composite FK from ${reference.name}") + entity.storeReferenceInCache(reference, parentEntity) + return parentEntity + } + + @Suppress("UNCHECKED_CAST") + val refValue: REF = entity.resolveColumnValue(reference) as? REF + ?: error("Reference column ${reference.name} has no value for entity ${entity.id}") + + val parentEntity = lookupParentEntity(factory, reference, refValue) + ?: error("Referenced entity not found for column ${reference.name} with value $refValue") + + entity.storeReferenceInCache(reference, parentEntity) + + return parentEntity + } +} + +/** + * R2DBC accessor returned for nullable many-to-one references created via `optionalReferencedOn`. + * + * Mirrors [Accessor] but allows `null` reads and writes. Uses the same val + invoke()/set() pattern + * because `setValue` cannot be made `suspend` (Kotlin does not allow suspend property delegates). + */ +@ExperimentalR2dbcDaoApi +class OptionalAccessor, REF : Any>( + internal val reference: Column, + internal val factory: EntityClass, + internal val entity: Entity<*>, + /** Composite-FK child→parent column map. `null` for single-column references. */ + internal val references: Map, Column<*>>? = null +) { + /** Property delegate operator — returns this accessor itself, used to expose [invoke] / [set]. */ + operator fun getValue(thisRef: Any?, property: KProperty<*>): OptionalAccessor { + return this + } + + /** Writes the link to [value] (or clears it when `null`) into the underlying [reference] column(s). */ + fun set(value: Parent?) { + entity.requireTrackedByCurrentTransaction() + + if (value != null) { + if (entity.db != value.db) { + error("Cannot link entities from different databases") + } + + 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.resolveColumnValue(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 { + if (references != null) { + references.keys.forEach { childColumn -> + @Suppress("UNCHECKED_CAST") + entity.writeValues[childColumn as Column] = null + } + } else { + entity.writeValues[reference as Column] = null + } + } + + 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 EntityClass>, entity as Entity) + } + } + + entity.storeReferenceInCache(reference, value) + } + + /** + * Suspending read of the optionally referenced parent entity. Returns `null` when the underlying + * reference column(s) are unset. + */ + suspend operator fun invoke(): Parent? { + if (entity.hasInReferenceCache(reference)) { + 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 -> + entity.resolveColumnValue(childColumn) == 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.resolveColumnValue(reference) as? REF + + if (refValue == null) { + entity.storeReferenceInCache(reference, null) + return null + } + + val parentEntity = lookupParentEntity(factory, reference, refValue) + + entity.storeReferenceInCache(reference, parentEntity) + + return parentEntity + } +} + +/** + * Rejects a write to an entity that the current transaction does not track. + * + * Plain column writes get this check from `Entity.setValue`, but reference writes assign + * [Entity.writeValues] directly and would otherwise discard the assignment silently: the + * `scheduleUpdate` that follows only fires for entities already stored in the cache, so an + * unattached entity would report success while nothing reaches the database. + */ +@Suppress("UNCHECKED_CAST") +private fun Entity<*>.requireTrackedByCurrentTransaction() { + (klass as EntityClass>).invalidateEntityInCache(this as Entity) +} + +private fun copyCompositeFkValues( + child: Entity<*>, + parent: Entity<*>, + references: Map, Column<*>> +) { + references.forEach { (childColumn, parentColumn) -> + val parentRaw: Any? = parent.resolveColumnValue(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 [Accessor.invoke] / [OptionalAccessor.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: EntityClass, + child: Entity<*>, + references: Map, Column<*>> +): Parent? { + val parentIdValue = CompositeID { id -> + references.forEach { (childColumn, parentColumn) -> + val rawChild = child.resolveColumnValue(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 [Accessor.invoke] and [OptionalAccessor.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: EntityClass, + 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-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference.kt new file mode 100644 index 0000000000..efb683af7b --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/BackReference.kt @@ -0,0 +1,88 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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 DaoEntityID can't trigger `flushInserts` + * (which is `suspend`) from a non-suspend getter. + */ +private suspend fun Entity<*>.ensureIdFlushed() { + if (id._value != null) return + TransactionManager.current().entityCache.flush() +} + +/** + * Class responsible for implementing property delegates of the read-only properties involved in a table + * relation between two [Entity] classes, which retrieves the child entity that references the parent entity. + * + * R2DBC counterpart of JDBC's `BackReference` from `References.kt`. The delegate exposes the parent as a + * `suspend () -> Parent` factory because the underlying lookup is suspending. + * + * @param reference The reference column defined on the child entity's associated table. + * @param factory The [EntityClass] associated with the child entity that references the parent entity. + */ +@ExperimentalR2dbcDaoApi +class BackReference, ChildID : Any, in Child : Entity, REF>( + reference: Column, + factory: EntityClass, + references: Map, Column<*>>? = null +) { + internal val delegate = Referrers( + reference, + factory, + cache = true, + references = references + ) + + operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent { + val referrers = delegate.getValue(thisRef, property) + + return suspend { + thisRef.ensureIdFlushed() + referrers.single() + } + } +} + +/** + * Class responsible for implementing property delegates of the read-only properties involved in an optional table + * relation between two [Entity] classes, which retrieves the child entity that optionally references the parent entity. + * + * R2DBC counterpart of JDBC's `OptionalBackReference` from `References.kt`. The delegate exposes the parent as a + * `suspend () -> Parent?` factory because the underlying lookup is suspending. + * + * @param reference The nullable reference column defined on the child entity's associated table. + * @param factory The [EntityClass] associated with the child entity that optionally references the parent entity. + */ +@ExperimentalR2dbcDaoApi +class OptionalBackReference, ChildID : Any, in Child : Entity, REF>( + reference: Column, + factory: EntityClass, + references: Map, Column<*>>? = null +) { + internal val delegate = Referrers( + reference, + factory, + cache = true, + references = references + ) + + operator fun getValue(thisRef: Child, property: KProperty<*>): suspend () -> Parent? { + val referrers = delegate.getValue(thisRef, property) + + return suspend { + thisRef.ensureIdFlushed() + referrers.singleOrNull() + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/DeferredQuery.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/DeferredQuery.kt new file mode 100644 index 0000000000..b0bba2da31 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/DeferredQuery.kt @@ -0,0 +1,28 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +import kotlinx.coroutines.flow.FlowCollector +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.r2dbc.SizedIterable + +internal class DeferredQuery( + private val base: suspend () -> SizedIterable, + private val modifier: (SizedIterable) -> SizedIterable = { it } +) : SizedIterable { + private suspend fun get() = modifier(base()) + + override suspend fun collect(collector: FlowCollector) = get().collect(collector) + + override suspend fun count() = get().count() + + override suspend fun empty() = get().empty() + + override fun limit(count: Int) = DeferredQuery(base) { modifier(it).limit(count) } + + override fun offset(start: Long) = DeferredQuery(base) { modifier(it).offset(start) } + + override fun copy() = DeferredQuery(base, modifier) + + override fun orderBy(vararg order: Pair, SortOrder>) = + DeferredQuery(base) { modifier(it).orderBy(*order) } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/EagerLoading.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/EagerLoading.kt new file mode 100644 index 0000000000..3817d2de49 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/EagerLoading.kt @@ -0,0 +1,387 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +import kotlinx.coroutines.flow.toList +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.inList +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.flushCache +import org.jetbrains.exposed.v1.dao.r2dbc.getCompositeID +import org.jetbrains.exposed.v1.dao.r2dbc.hasSingleReferenceWithReferee +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. + * + * 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. + */ +@ExperimentalR2dbcDaoApi +suspend fun , REF : Entity<*>, 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 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. + */ +@ExperimentalR2dbcDaoApi +suspend fun , REF : Entity<*>, 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`. + */ +@ExperimentalR2dbcDaoApi +suspend fun > SRC.load( + vararg relations: KProperty1, Any?> +): SRC = apply { + listOf(this).with(*relations) +} + +@Suppress("UNCHECKED_CAST", "NestedBlockDepth") +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 Accessor<*, *, *> -> + preloadReference(refObject as Accessor, Any>) + is OptionalAccessor<*, *, *> -> + preloadOptionalReference(refObject as OptionalAccessor, Any>) + is Referrers<*, *, *, *, *> -> { + (refObject as Referrers, Any, Entity, Any>).let { referrers -> + val refColumns = referrers.allReferences + val delegateRefColumn = referrers.reference + val orderByExpressions = referrers.getOrderByExpressions() + val loaded = if (hasSingleReferenceWithReferee(refColumns)) { + val castReferee = delegateRefColumn.referee()!! + val refIds = this.map { entity -> entity.getRefereeId(castReferee, delegateRefColumn) } + referrers.factory.warmUpReferences(refIds, delegateRefColumn, orderByExpressions) + } else { + val refIds = this.map { it.getCompositeReferrerId(refColumns) } + referrers.factory.warmUpCompositeIdReferences(refIds, refColumns, delegateRefColumn, orderBy = orderByExpressions) + } + storeReferenceCache(delegateRefColumn) + loaded + } + } + is InnerTableLinkAccessor<*, *, *, *> -> + preloadInnerTableLink(refObject as InnerTableLinkAccessor, Any, Entity>) + is BackReference<*, *, *, *, *> -> { + (refObject.delegate as Referrers, Any, Entity, Any>).let { referrers -> + val refColumns = referrers.allReferences + val delegateRefColumn = referrers.reference + val orderByExpressions = referrers.getOrderByExpressions() + val loaded = if (hasSingleReferenceWithReferee(refColumns)) { + val castReferee = delegateRefColumn.referee()!! + val refIds = this.map { entity -> entity.getRefereeId(castReferee, delegateRefColumn) } + referrers.factory.warmUpReferences(refIds, delegateRefColumn, orderByExpressions) + } else { + val refIds = this.map { it.getCompositeReferrerId(refColumns) } + referrers.factory.warmUpCompositeIdReferences(refIds, refColumns, delegateRefColumn, orderBy = orderByExpressions) + } + storeReferenceCache(delegateRefColumn) + loaded + } + } + is OptionalBackReference<*, *, *, *, *> -> { + (refObject.delegate as Referrers, Any, Entity, Any>).let { referrers -> + val refColumns = referrers.allReferences + val delegateRefColumn = referrers.reference + val orderByExpressions = referrers.getOrderByExpressions() + val loaded = if (hasSingleReferenceWithReferee(refColumns)) { + @Suppress("UNCHECKED_CAST") + val refIds = this.map { it.resolveColumnValue(delegateRefColumn.referee()!!) } + referrers.factory.warmUpOptReferences(refIds, delegateRefColumn as Column, orderByExpressions) + } else { + val refIds = this.map { it.getCompositeReferrerId(refColumns) } + referrers.factory.warmUpCompositeIdReferences(refIds, refColumns, delegateRefColumn, orderBy = orderByExpressions) + } + storeReferenceCache(delegateRefColumn) + loaded + } + } + 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 [Accessor]-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: Accessor, Any> +): List> { + val reference = accessor.reference + val factory = accessor.factory + + accessor.references?.let { refs -> + return preloadCompositeReference(this, factory, reference as Column, refs) + } + + val refIds = mapNotNull { entity -> entity.resolveColumnValue(reference) } + if (refIds.isEmpty()) return emptyList() + + val referee = reference.referee ?: return emptyList() + val condition = buildInListCondition(referee, refIds.distinct()) + val loadedParents = factory.find { condition }.toList() + + val parentByKey = loadedParents.indexedByRefereeValue(referee) + forEach { child -> + val refValue = child.resolveColumnValue(reference) ?: return@forEach + val parent = parentByKey[normalizeRefKey(refValue)] ?: return@forEach + child.storeReferenceInCache(reference, parent) + } + + return loadedParents +} + +/** + * Bulk-loads parents referenced by an [OptionalAccessor]-backed property. + */ +@Suppress("UNCHECKED_CAST") +private suspend fun List>.preloadOptionalReference( + accessor: OptionalAccessor, Any> +): 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.resolveColumnValue(reference) } + + val referee = reference.referee ?: return emptyList() + 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.resolveColumnValue(reference) + if (refValue == null) { + child.storeReferenceInCache(reference, null) + } else { + parentByKey[normalizeRefKey(refValue)]?.let { parent -> + child.storeReferenceInCache(reference, parent) + } + } + } + + return loadedParents +} + +/** + * Composite-FK preload: iterate each child, build the composite parent id from its FK columns, + * and fetch via [EntityClass.findById]. Each fetched parent is stored in the transaction's + * entity cache (by `findById`) and pinned on the child's per-entity reference cache. + */ +@Suppress("UNCHECKED_CAST") +private suspend fun preloadCompositeReference( + children: List>, + factory: EntityClass>, + reference: Column, + references: Map, Column<*>> +): List> { + val loaded = mutableListOf>() + children.forEach { child -> + val rawValues = references.map { (childColumn, parentColumn) -> + val raw = child.resolveColumnValue(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> + 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> { + val result = HashMap>(size) + for (parent in this) { + val raw = parent.resolveColumnValue(referee) ?: continue + result[normalizeRefKey(raw)] = parent + } + return result +} + +@Suppress("UNCHECKED_CAST") +private fun buildInListCondition(referee: Column<*>, refIds: List): org.jetbrains.exposed.v1.core.Op { + 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 Entity<*>.getRefereeId(refereeColumn: Column<*>, delegateRefColumn: Column<*>): Any { + val refereeValue = resolveColumnValue(refereeColumn) + ?: error("Referee column ${refereeColumn.name} has no value for entity $id") + return refereeValue.takeUnless { + delegateRefColumn.columnType !is EntityIDColumnType<*> && it is EntityID<*> + } ?: (refereeValue as EntityID<*>).value +} + +private fun Entity<*>.getCompositeReferrerId(refColumns: Map, Column<*>>): CompositeID = getCompositeID { + @Suppress("UNCHECKED_CAST") + refColumns.map { (child, parent) -> child to (resolveColumnValue(parent) as EntityID<*>).value } +} + +/** + * Mirrors JDBC's `storeReferenceCache(reference, prop)`. In JDBC the property delegate's + * `getValue` returns the loaded referrers directly, so `prop.get(entity)` works. In R2DBC the + * delegates return accessor wrappers, so we read from [EntityCache.referrers] instead. + */ +private fun List>.storeReferenceCache(reference: Column<*>) { + val cache = TransactionManager.current().entityCache + forEach { entity -> + val cached = cache.getReferrers>(entity.id, reference) + if (cached != null) { + entity.storeReferenceInCache(reference, cached) + } + } +} + +private suspend fun List>.preloadInnerTableLink( + accessor: InnerTableLinkAccessor, Any, Entity> +): 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, Entity>> = rows.map { row -> + @Suppress("UNCHECKED_CAST") + val parentId = row[sourceColumn] as EntityID + val targetEntity = target.wrapRow(row) as Entity + parentId to targetEntity + } + + val groupedBySourceId: Map, List>> = pairs + .groupBy({ it.first }, { it.second }) + + toLoad.forEach { id -> + cache.getOrPutReferrers(id, sourceColumn) { + SizedCollection(groupedBySourceId[id] ?: emptyList()) + } + } + + val parentsById: Map, Entity> = 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() +} + +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/v1/dao/r2dbc/relationships/InnerTableLink.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink.kt new file mode 100644 index 0000000000..7a43353ce3 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/InnerTableLink.kt @@ -0,0 +1,230 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityChangeType +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +import org.jetbrains.exposed.v1.dao.r2dbc.executeAsPartOfEntityLifecycle +import org.jetbrains.exposed.v1.dao.r2dbc.registerChange +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 + +/** + * Class responsible for implementing property delegates of the read-write properties involved in a many-to-many + * relation, which uses an intermediate (join) table. + * + * R2DBC counterpart of JDBC's `InnerTableLink`. Because R2DBC's [SizedIterable] is a coroutine `Flow` (not a + * blocking `Iterable`), the property delegate produces an [InnerTableLinkAccessor] that itself acts as the + * `SizedIterable` returned to user code — see [provideDelegate]. + * + * @param table The intermediate table containing reference columns to both child and parent entities. + * @param sourceTable The [IdTable] associated with the source child entity. + * @param target The [EntityClass] for the target parent entity. + * @param _sourceColumn The intermediate table's reference column for the child entity class. If left `null`, + * this will be inferred from the provided intermediate [table] columns. + * @param _targetColumn The intermediate table's reference column for the parent entity class. If left `null`, + * this will be inferred from the provided intermediate [table] columns. + */ +@Suppress("UNCHECKED_CAST") +@ExperimentalR2dbcDaoApi +class InnerTableLink, ID : Any, Target : Entity>( + val table: Table, + sourceTable: IdTable, + val target: EntityClass, + _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}" + } + } + } + + /** The reference identity column for the child entity class. */ + val sourceColumn: Column> = _sourceColumn + ?: table.columns.singleOrNull { it.referee == sourceTable.id } as? Column> + ?: error("Table does not reference source") + + /** The reference identity column for the parent entity class. */ + 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() + + /** + * Provides the property delegate by binding this link to the source [thisRef] and returning a + * suspending [InnerTableLinkAccessor]. The accessor itself implements [SizedIterable] so that + * `entity.targets` can be iterated as a Flow. + */ + operator fun provideDelegate( + thisRef: Source, + property: KProperty<*> + ): InnerTableLinkAccessor = InnerTableLinkAccessor(this, thisRef) + + /** 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)) +} + +/** + * Property delegate companion to [InnerTableLink] — implements [SizedIterable] over the linked targets + * of [entity] via the join table defined by [link]. R2DBC-specific because the iteration is built + * around `Flow.collect`, whereas the JDBC implementation uses blocking iteration directly on + * `InnerTableLink`. + * + * Assignment (`entity.targets = SizedCollection(values)`) is captured into the entity cache's + * `pendingInnerTableLinkUpdates` and replayed during the next [EntityCache.flush] — this keeps the + * write surface non-suspending so it can be used inside property setters. + * + * [SizedIterable] methods are delegated to an internal [DeferredQuery] so the iteration logic is + * defined in one place ([doQuery]) and chained operations (`limit`, `offset`, `orderBy`) reuse the + * same lazy-execution machinery. + */ +@Suppress("UNCHECKED_CAST") +@ExperimentalR2dbcDaoApi +class InnerTableLinkAccessor, ID : Any, Target : Entity>( + val link: InnerTableLink, + val entity: Source +) : SizedIterable by DeferredQuery({ doQuery(link, entity) }) { + + operator fun getValue(thisRef: Source, property: KProperty<*>): SizedIterable = this + + operator fun setValue(thisRef: Source, property: KProperty<*>, value: SizedIterable) { + val entityCache = TransactionManager.current().entityCache + entityCache.pendingInnerTableLinkUpdates.add { + setReference(link, entity, value) + } + } + + override fun copy(): SizedIterable = InnerTableLinkAccessor(link, entity) +} + +/** + * Resolves linked targets from the entity's reference cache, for reads with no transaction in context. + * + * The cache is only populated when `keepLoadedReferencesOutOfTransaction` is enabled, so a miss is a + * usage error and is reported as one. Returning the raw cache entry instead would hand back `null` + * typed as a non-null [SizedIterable] and surface later as an unrelated `NullPointerException`. + * Mirrors the equivalent branch in `Referrers`. + */ +@Suppress("UNCHECKED_CAST") +private fun Entity<*>.cachedLinkedTargets(sourceColumn: Column<*>): SizedIterable { + if (!hasInReferenceCache(sourceColumn)) { + error( + "Linked entities for $sourceColumn are not in the entity cache. Reading a `via` relation " + + "without a transaction in context requires DatabaseConfig.keepLoadedReferencesOutOfTransaction " + + "to be enabled, and the relation to have been loaded earlier inside a transaction." + ) + } + return when (val cached = getReferenceFromCache(sourceColumn)) { + is SizedIterable<*> -> cached as SizedIterable + null -> emptySized() + else -> error("Cached linked entities have unexpected type: ${cached::class}") + } +} + +private suspend fun , ID : Any, Target : Entity> doQuery( + link: InnerTableLink, + entity: Source +): SizedIterable { + if (entity.id._value == null && !entity.isNewEntity()) return emptySized() + val transaction = TransactionManager.currentOrNull() + ?: return entity.cachedLinkedTargets(link.sourceColumn) + + if (entity.id._value == null) { + transaction.entityCache.flush() + } + + 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(entity.id, link.sourceColumn, query).also { + entity.storeReferenceInCache(link.sourceColumn, it) + } +} + +@Suppress("UNCHECKED_CAST") +private suspend fun , ID : Any, Target : Entity> setReference( + link: InnerTableLink, + entity: Source, + value: SizedIterable +) { + val tx = TransactionManager.current() + val entityCache = tx.entityCache + val valueList = value.toList() + val oldValue = doQuery(link, entity).toList() + val existingIds = oldValue.map { it.id }.toSet() + entityCache.referrers[link.sourceColumn]?.remove(entity.id) + + val targetIds = valueList.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 + } + } + + tx.registerChange(entity.klass as EntityClass<*, Entity<*>>, entity.id, EntityChangeType.Updated) + + val targetClass = (valueList.firstOrNull() ?: oldValue.firstOrNull())?.klass + if (targetClass != null) { + (existingIds + targetIds).forEach { targetId -> + tx.registerChange(targetClass as EntityClass<*, Entity<*>>, targetId, EntityChangeType.Updated) + } + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers.kt new file mode 100644 index 0000000000..bcd0ec0b24 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/Referrers.kt @@ -0,0 +1,164 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +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.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import org.jetbrains.exposed.v1.dao.r2dbc.entityCache +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 + +/** + * Class responsible for implementing property delegates of the read-only properties involved in a one-to-many + * relation, which retrieves all child entities that reference the parent entity. + * + * R2DBC counterpart of JDBC's `Referrers` from `References.kt`. The property delegate returns a + * [SizedIterable] backed by a [DeferredQuery] — iteration is suspended until terminal operations + * (`collect`, `count`, etc.) are invoked. + * + * @param reference The reference column defined on the child entity's associated table. + * @param factory The [EntityClass] associated with the child entity that references the parent entity. + * @param cache Whether loaded reference entities should be stored in the [org.jetbrains.exposed.v1.dao.r2dbc.EntityCache]. + */ +@ExperimentalR2dbcDaoApi +class Referrers, ChildID : Any, out Child : Entity, REF>( + val reference: Column, + val factory: EntityClass, + 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>>() + + /** Returns the order by expressions as an array. */ + internal fun getOrderByExpressions(): Array, SortOrder>> = orderByExpressions.toTypedArray() + + /** + * 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`. + */ + 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("UNCHECKED_CAST") + operator fun getValue(thisRef: Parent, property: KProperty<*>): SizedIterable { + return DeferredQuery(base = { + doQuery( + reference, + factory as EntityClass>, + cache, + allReferences, + getOrderByExpressions(), + thisRef as Entity + ) as SizedIterable + }) + } + + /** 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()) +} + +@Suppress("UNCHECKED_CAST", "NestedBlockDepth", "SpreadOperator", "LongParameterList") +private suspend fun , REF> doQuery( + reference: Column, + factory: EntityClass, + cache: Boolean, + allReferences: Map, Column<*>>, + orderByExpressions: Array, SortOrder>>, + entity: Entity<*> +): SizedIterable { + val transaction = TransactionManager.currentOrNull() + + return if (transaction == null) { + if (entity.id._value == null) { + emptySized() + } else if (entity.hasInReferenceCache(reference)) { + val cached = entity.getReferenceFromCache(reference) + when (cached) { + is SizedIterable<*> -> cached as SizedIterable + null -> emptySized() + else -> error("Cached referrer has unexpected type: ${cached::class}") + } + } else { + error( + "Referring entities for $reference are not in the entity cache. Reading a referrers " + + "relation without a transaction in context requires " + + "DatabaseConfig.keepLoadedReferencesOutOfTransaction to be enabled, and the relation " + + "to have been loaded earlier inside a transaction." + ) + } + } else { + if (entity.id._value == null) { + transaction.entityCache.flush() + } + + val isComposite = allReferences.size > 1 || allReferences.values.firstOrNull()?.table is CompositeIdTable + val query: suspend () -> SizedIterable = if (!isComposite) { + val referee = reference.referee!! + val refereeValue = with(entity) { referee.lookup() } + + val needsEntityIdUnwrap = reference.columnType !is EntityIDColumnType<*> && + referee.columnType is EntityIDColumnType<*> && refereeValue is EntityID<*> + + val refValue = if (needsEntityIdUnwrap) refereeValue.value as REF else refereeValue as REF + ; { + factory.find { reference eq refValue } + .orderBy(*orderByExpressions) + } + } else { + val parentValuesByChildColumn = allReferences.map { (childColumn, parentColumn) -> + val parentValueRaw = with(entity) { (parentColumn as Column).lookup() } + val parentValue = if (parentValueRaw is EntityID<*> && childColumn.columnType !is EntityIDColumnType<*>) { + parentValueRaw._value + } else { + parentValueRaw + } + childColumn to parentValue + } + + ; { + factory.find { + parentValuesByChildColumn.map { (childColumn, value) -> + (childColumn as Column) eq value + }.reduce { acc, next -> acc and next } + }.orderBy(*orderByExpressions) + } + } + + val result = if (cache) { + transaction.entityCache.getOrPutReferrers(entity.id, reference, query) + } else { + query() + } + + entity.storeReferenceInCache(reference, result) + result + } +} diff --git a/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/RelationshipExtensions.kt b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/RelationshipExtensions.kt new file mode 100644 index 0000000000..d4700f70e1 --- /dev/null +++ b/exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/relationships/RelationshipExtensions.kt @@ -0,0 +1,60 @@ +package org.jetbrains.exposed.v1.dao.r2dbc.relationships + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.dao.r2dbc.Entity +import org.jetbrains.exposed.v1.dao.r2dbc.EntityClass +import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi +import kotlin.reflect.KProperty + +/** + * Class representing a table relation between two [Entity] classes, which is responsible for + * retrieving the parent entity referenced by the child entity. R2DBC counterpart of JDBC's `Reference` + * from `References.kt`; the property delegate yields an [Accessor] for suspending lookups. + * + * @param reference The reference column defined on the child entity's associated table. + * @param factory The [EntityClass] associated with the parent entity referenced by the child entity. + */ +@ExperimentalR2dbcDaoApi +class Reference, REF : Any>( + val reference: Column, + val factory: EntityClass, + /** + * Composite-FK child→parent column map (mirrors JDBC's `Reference.references`). `null` for + * single-column references — [Accessor] falls back to `reference.referee`. + */ + val references: Map, Column<*>>? = null +) { + /** Wires up the property delegate by returning an [Accessor] bound to the source [thisRef] entity. */ + operator fun > provideDelegate( + thisRef: SRC, + property: KProperty<*> + ): Accessor { + return Accessor(reference, factory, thisRef, references) + } +} + +/** + * Class representing an optional table relation between two [Entity] classes, which is responsible for + * retrieving the parent entity optionally referenced by the child entity. R2DBC counterpart of JDBC's + * `OptionalReference`; the property delegate yields an [OptionalAccessor] for suspending lookups. + * + * @param reference The nullable reference column defined on the child entity's associated table. + * @param factory The [EntityClass] associated with the parent entity optionally referenced by the child entity. + */ +@ExperimentalR2dbcDaoApi +class OptionalReference, REF : Any>( + val reference: Column, + val factory: EntityClass, + /** + * Composite-FK child→parent column map (mirrors JDBC's `OptionalReference.references`). + */ + val references: Map, Column<*>>? = null +) { + /** Wires up the property delegate by returning an [OptionalAccessor] bound to the source [thisRef] entity. */ + operator fun > provideDelegate( + thisRef: SRC, + property: KProperty<*> + ): OptionalAccessor { + return OptionalAccessor(reference, factory, thisRef, references) + } +} 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..ce0994a9bc --- /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.v1.dao.r2dbc.EntityLifecycleInterceptor 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-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-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 d6b815edd5..c7d60d2ae0 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 bdd6607649..8501a0ebe5 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 e1e091bf83..454dfb3d72 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 @@ -15,7 +15,6 @@ import org.jetbrains.exposed.v1.dao.flushCache 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.constraintNamePart import org.jetbrains.exposed.v1.tests.currentDialectTest @@ -25,7 +24,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 @@ -97,7 +95,6 @@ class DefaultsTest : DatabaseTestsBase() { assertEquals(defaultValue, returnedDefault, "Expected clientDefault to return $defaultValue, but was $returnedDefault") } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsWithExplicit01() { withTables(TableWithDBDefault) { @@ -118,7 +115,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 @@ -140,7 +136,6 @@ class DefaultsTest : DatabaseTestsBase() { } } - @Tag(MISSING_R2DBC_TEST) @Test fun testDefaultsInvokedOnlyOncePerEntity() { withTables(TableWithDBDefault) { @@ -650,7 +645,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-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-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/main/kotlin/org/jetbrains/exposed/v1/r2dbc/tests/R2dbcDatabaseTestsBase.kt b/exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/tests/R2dbcDatabaseTestsBase.kt index b6e806ada4..6a01b3ee26 100644 --- a/exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/tests/R2dbcDatabaseTestsBase.kt +++ b/exposed-r2dbc-tests/src/main/kotlin/org/jetbrains/exposed/v1/r2dbc/tests/R2dbcDatabaseTestsBase.kt @@ -27,7 +27,7 @@ val TEST_DIALECTS: HashSet = System.getProperty( private val registeredOnShutdown = HashSet() -internal var currentTestDB by nullableTransactionScope() +var currentTestDB by nullableTransactionScope() @ParameterizedClass(name = "name: {2}, container: {0}, dialect: {1}", allowZeroInvocations = true) @MethodSource("data") 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/dml/InsertTests.kt b/exposed-r2dbc-tests/src/test/kotlin/org/jetbrains/exposed/v1/r2dbc/sql/tests/shared/dml/InsertTests.kt index 063c96607d..ef6031abfd 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 @@ -32,6 +32,7 @@ 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.assertDoesNotThrow +import java.util.Random import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -186,7 +187,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-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/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/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 f86ba5b1c2..aab839068f 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 @@ -1032,7 +1030,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 379bd11289..b2f2a0f2b6 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 @@ -399,7 +398,6 @@ class InsertTests : DatabaseTestsBase() { } // https://github.com/JetBrains/Exposed/issues/192 - @Tag(MISSING_R2DBC_TEST) @Test fun testInsertWithColumnNamedWithKeyword() { withTables(OrderedDataTable) { @@ -587,7 +585,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/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") { 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/EntityTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/v1/tests/shared/entities/EntityTests.kt index e712a72840..efb3a1ab4a 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 @@ -97,7 +99,6 @@ object EntityTestsData { } } -@Tag(MISSING_R2DBC_TEST) @Suppress("LargeClass") class EntityTests : DatabaseTestsBase() { @Test @@ -1782,4 +1783,16 @@ class EntityTests : DatabaseTestsBase() { flushCache() } } + + @Test + fun testForIds() { + withTables(Humans) { + val h1 = Human.new { h = "h1" }.id.value + val h2 = Human.new { h = "h2" }.id.value + Human.new { h = "h3" } + + val byIds = Human.forIds(listOf(h1, h2)).toList() + assertEquals(setOf("h1", "h2"), byIds.map { it.h }.toSet()) + } + } } 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() { 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 -> diff --git a/exposed-version-catalog/README.md b/exposed-version-catalog/README.md index bc5c2bc9c8..0ce2fafc42 100644 --- a/exposed-version-catalog/README.md +++ b/exposed-version-catalog/README.md @@ -39,6 +39,7 @@ into nested accessors, for example: | `exposed-core` | `exposedLibs.core` | | `exposed-jdbc` | `exposedLibs.jdbc` | | `exposed-r2dbc` | `exposedLibs.r2dbc` | +| `exposed-dao-r2dbc` | `exposedLibs.dao.r2dbc` | | `exposed-kotlin-datetime` | `exposedLibs.kotlin.datetime` | | `spring-transaction` | `exposedLibs.spring.transaction` | diff --git a/exposed-version-catalog/build.gradle.kts b/exposed-version-catalog/build.gradle.kts index 298d72c40d..a1f1d56241 100644 --- a/exposed-version-catalog/build.gradle.kts +++ b/exposed-version-catalog/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.exposed.gradle.publishesMavenArtifact + plugins { `version-catalog` alias(libs.plugins.maven.publish) @@ -5,19 +7,12 @@ plugins { group = "org.jetbrains.exposed" -val excludedFromCatalog = setOf( - "exposed-tests", - "exposed-r2dbc-tests", - "exposed-jdbc-r2dbc-tests", - "exposed-version-catalog", -) - catalog { versionCatalog { version("exposed", project.version.toString()) rootProject.subprojects + .filter { it.name != project.name && it.publishesMavenArtifact() } .map { it.name } - .filter { it !in excludedFromCatalog } .sorted() .forEach { moduleName -> val alias = moduleName.removePrefix("exposed-") diff --git a/samples/README.md b/samples/README.md index 435d8dd723..192a46f0fe 100644 --- a/samples/README.md +++ b/samples/README.md @@ -4,6 +4,7 @@ This section contains samples for different cases of using Exposed. - [exposed-ktor](exposed-ktor): Backend application with CRUD (Create, Read, Update, Delete) endpoints, built using Ktor and Exposed. - [exposed-ktor-r2dbc](exposed-ktor-r2dbc): Backend application built using Ktor, Exposed, and PostgreSQL R2DBC. +- [exposed-dao-showcase](exposed-dao-showcase): The same DAO application implemented twice, with the JDBC DAO and the R2DBC DAO, for side-by-side comparison. - [exposed-spring](exposed-spring): Spring Boot 3 based project with CRUD (Create, Read, Update, Delete) operations. - [exposed-migration](exposed-migration): Application illustrating how to generate migration scripts with Exposed. - [exposed-gradle-plugin-sample](exposed-gradle-plugin-sample): Project demonstrating the Exposed Gradle plugin and its `generateMigrations` task. diff --git a/samples/exposed-dao-showcase/README.md b/samples/exposed-dao-showcase/README.md new file mode 100644 index 0000000000..4e498433fb --- /dev/null +++ b/samples/exposed-dao-showcase/README.md @@ -0,0 +1,63 @@ +# exposed-dao-showcase + +The same application implemented twice — once with the JDBC DAO and once with the R2DBC DAO — so the +two APIs can be compared side by side. + +The domain is a small brokerage: brokers, clients, portfolios, instruments, tags, and trades. It +exercises the relationship types that differ most between the drivers: many-to-one references, +optional references, one-to-many referrers, and many-to-many links. + +| Module | Artifacts | +|---------|-------------------------------------------------| +| `jdbc` | `exposed-jdbc` + `exposed-dao` | +| `r2dbc` | `exposed-r2dbc` + `exposed-dao-r2dbc` | + +Both use H2 in memory, so no database setup is required. + +## Requirements + +Exposed **1.3.2 or later**: `exposed-dao-r2dbc` is not published in earlier releases. + + + +## Running + +```bash +./gradlew :jdbc:run +./gradlew :r2dbc:run +``` + +Each module starts a Ktor server on port 8080, so run one at a time. Seed the database first: + +```bash +curl -X POST http://localhost:8080/seed +``` + +Then try the endpoints, for example: + +```bash +curl http://localhost:8080/clients/1 +curl http://localhost:8080/clients/1/trades +curl http://localhost:8080/instruments +``` + +## What to compare + +The table definitions are identical between the two modules — they come from `exposed-core` and are +shared by both drivers. The differences are concentrated in: + +- **`model/entities/`** — reference properties are `var` under JDBC and `val` under R2DBC. +- **`routes/`** — `transaction { }` becomes `suspendTransaction { }`, reference reads become `x()`, + writes become `x.set(...)`, and collections need `.toList()`. +- **`routes/SeedRoutes.kt`** — the R2DBC version additionally demonstrates `newDeferred { }`, which + batches several inserts into one statement and has no JDBC counterpart. + +For a step-by-step account of every difference, see the +[JDBC DAO to R2DBC DAO migration guide](https://www.jetbrains.com/help/exposed/migration-guide-dao-jdbc-to-r2dbc.html). + +## Note on the R2DBC DAO + +`exposed-dao-r2dbc` is an experimental preview. Its API may change in incompatible ways between +releases, which is why the `r2dbc` module opts in to `@ExperimentalR2dbcDaoApi` in its build file. diff --git a/samples/exposed-dao-showcase/gradle.properties b/samples/exposed-dao-showcase/gradle.properties new file mode 100644 index 0000000000..7fc6f1ff27 --- /dev/null +++ b/samples/exposed-dao-showcase/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/samples/exposed-dao-showcase/gradle/libs.versions.toml b/samples/exposed-dao-showcase/gradle/libs.versions.toml new file mode 100644 index 0000000000..78914c4bf2 --- /dev/null +++ b/samples/exposed-dao-showcase/gradle/libs.versions.toml @@ -0,0 +1,31 @@ +[versions] +kotlin-version = "2.3.20" +ktor-version = "3.3.2" +exposed-version = "1.3.2" +h2-version = "2.4.240" +h2-r2dbc-version = "1.1.0.RELEASE" +logback-version = "1.5.21" + +[libraries] +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor-version" } +ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor-version" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor-version" } +ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor-version" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor-version" } + +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed-version" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed-version" } +exposed-r2dbc = { module = "org.jetbrains.exposed:exposed-r2dbc", version.ref = "exposed-version" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed-version" } +exposed-dao-r2dbc = { module = "org.jetbrains.exposed:exposed-dao-r2dbc", version.ref = "exposed-version" } +exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed-version" } + +h2 = { module = "com.h2database:h2", version.ref = "h2-version" } +r2dbc-h2 = { module = "io.r2dbc:r2dbc-h2", version.ref = "h2-r2dbc-version" } + +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-version" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor-version" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-version" } diff --git a/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.jar b/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..7454180f2a Binary files /dev/null and b/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.properties b/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..e48eca5755 --- /dev/null +++ b/samples/exposed-dao-showcase/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/exposed-dao-showcase/gradlew b/samples/exposed-dao-showcase/gradlew new file mode 100755 index 0000000000..1b6c787337 --- /dev/null +++ b/samples/exposed-dao-showcase/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/exposed-dao-showcase/gradlew.bat b/samples/exposed-dao-showcase/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/samples/exposed-dao-showcase/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega 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..e1bcf99781 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ktor) + alias(libs.plugins.kotlin.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(libs.exposed.core) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.dao) + implementation(libs.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..139776c965 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/Application.kt @@ -0,0 +1,16 @@ +@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..22f5608071 --- /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", "MagicNumber") + +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..a5daa710cb --- /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", "MagicNumber") + +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..44577b2576 --- /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", "MagicNumber") + +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..369927446b --- /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", "MagicNumber") + +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..127568337f --- /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", "MagicNumber") + +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..02d24777eb --- /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", "MagicNumber") + +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..26f91f5f58 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/plugins/Serialization.kt @@ -0,0 +1,14 @@ +@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..df56cb11f7 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/BrokerRoutes.kt @@ -0,0 +1,68 @@ +@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..c3e1931175 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/ClientRoutes.kt @@ -0,0 +1,87 @@ +@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..e009073d48 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/InstrumentRoutes.kt @@ -0,0 +1,98 @@ +@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..54d3c64496 --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/PortfolioRoutes.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.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.jdbc.transactions.transaction +import kotlin.time.Clock + +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..04cd370add --- /dev/null +++ b/samples/exposed-dao-showcase/jdbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/jdbc/routes/SeedRoutes.kt @@ -0,0 +1,175 @@ +@file:Suppress("InvalidPackageDeclaration", "MagicNumber") + +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 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 +import kotlin.time.Clock + +@Suppress("LongMethod") +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..9de5028841 --- /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 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 +import kotlin.time.Clock + +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..afba58ddf0 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ktor) + alias(libs.plugins.kotlin.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(libs.exposed.core) + implementation(libs.exposed.r2dbc) + implementation(libs.exposed.dao.r2dbc) + implementation(libs.exposed.kotlin.datetime) + + implementation(libs.r2dbc.h2) + + implementation(libs.logback.classic) +} + +kotlin { + compilerOptions { + optIn.add("kotlin.time.ExperimentalTime") + optIn.add("org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi") + // Required by Flow.flattenConcat(), used in SeedRoutes to collect newDeferred { } flows. + optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") + } +} 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..b07dc8fed7 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Broker.kt @@ -0,0 +1,18 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +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 +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.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/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..7b326c9019 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Client.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +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 +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass + +class Client(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Clients) + + var name by Clients.name + var email by Clients.email + val 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/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..26fff8f4c0 --- /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.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 +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.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/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..3c8b874784 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Portfolio.kt @@ -0,0 +1,18 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +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 +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass + +class Portfolio(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Portfolios) + + var name by Portfolios.name + val 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/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..9a1565929c --- /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.samples.broker.r2dbc.model.tables.Tags +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass + +class Tag(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(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..1d4b14407f --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/entities/Trade.kt @@ -0,0 +1,20 @@ +@file:Suppress("InvalidPackageDeclaration") + +package org.jetbrains.exposed.samples.broker.r2dbc.model.entities + +import org.jetbrains.exposed.samples.broker.r2dbc.model.tables.Trades +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntity +import org.jetbrains.exposed.v1.dao.r2dbc.IntEntityClass + +class Trade(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Trades) + + val client by Client referencedOn Trades.client + val instrument by Instrument referencedOn Trades.instrument + val 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/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..990e91ad5d --- /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", "MagicNumber") + +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..60a6edd3dd --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/model/tables/Clients.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 + +@Suppress("MagicNumber") +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..80feab222d --- /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", "MagicNumber") + +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..1d3b81f9ca --- /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", "MagicNumber") + +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..56b9b0f758 --- /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", "MagicNumber") + +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..69e8bc1ccf --- /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", "MagicNumber") + +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..eb191780f2 --- /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.samples.broker.r2dbc.model.tables.* +import org.jetbrains.exposed.v1.dao.r2dbc.EntityHook +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..7a960fc90a --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/BrokerRoutes.kt @@ -0,0 +1,67 @@ +@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 kotlinx.coroutines.flow.toList +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 + } + 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 + 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..1f5f1b3cdb --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/ClientRoutes.kt @@ -0,0 +1,86 @@ +@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 kotlinx.coroutines.flow.toList +import org.jetbrains.exposed.samples.broker.r2dbc.model.dto.* +import org.jetbrains.exposed.samples.broker.r2dbc.model.entities.* +import org.jetbrains.exposed.v1.dao.r2dbc.relationships.load +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) + } + 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..8215d70ca0 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/InstrumentRoutes.kt @@ -0,0 +1,101 @@ +@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 kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +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.dao.r2dbc.relationships.load +import org.jetbrains.exposed.v1.r2dbc.SizedCollection +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 + } + 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.map { it.name }.toList() + ) + } + 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 } + } + instrument.tags = SizedCollection(tags) + InstrumentDetailDTO( + id = instrument.id.value, + ticker = instrument.ticker, + name = instrument.name, + type = instrument.type, + tags = instrument.tags.map { it.name }.toList() + ) + } + 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..a66c8fa863 --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/PortfolioRoutes.kt @@ -0,0 +1,65 @@ +@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 kotlinx.coroutines.flow.toList +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 +import kotlin.time.Clock + +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() + } + 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..40aba4a76b --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/SeedRoutes.kt @@ -0,0 +1,193 @@ +@file:Suppress("InvalidPackageDeclaration", "MagicNumber") + +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 kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flattenConcat +import kotlinx.coroutines.flow.toList +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.SizedCollection +import org.jetbrains.exposed.v1.r2dbc.transactions.suspendTransaction +import kotlin.time.Clock + +@Suppress("LongMethod") +fun Application.seedRoutes() { + routing { + post("/seed") { + suspendTransaction { + // `new { }` suspends and flushes immediately, so it costs one INSERT per entity — + // that is the right default, and every other creation below uses it. + // + // These four tags are independent rows with no references, so they are a good fit for + // `newDeferred { }`: it schedules the insert without flushing and returns a cold Flow, + // and collecting the four flows together persists them in a single batched INSERT. + // + // Collect before creating anything else: `new { }` flushes the whole entity cache, so + // an eager creation in between would flush these tags early and split the batch. + val (tagTech, tagFinance, tagEnergy, tagIndex) = + listOf("tech", "finance", "energy", "index") + .map { tagName -> + Tag.newDeferred { + name = tagName + } + } + .asFlow() + .flattenConcat() + .toList() + + 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.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) + } + + 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() + } + + 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 + } + } + + 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..7838fed3bb --- /dev/null +++ b/samples/exposed-dao-showcase/r2dbc/src/main/kotlin/org/jetbrains/exposed/samples/broker/r2dbc/routes/TradeRoutes.kt @@ -0,0 +1,54 @@ +@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 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 +import kotlin.time.Clock + +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() + } + + 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/samples/exposed-dao-showcase/settings.gradle.kts b/samples/exposed-dao-showcase/settings.gradle.kts new file mode 100644 index 0000000000..17bc4759c1 --- /dev/null +++ b/samples/exposed-dao-showcase/settings.gradle.kts @@ -0,0 +1,10 @@ +rootProject.name = "exposed-dao-showcase" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +include("jdbc") +include("r2dbc") diff --git a/settings.gradle.kts b/settings.gradle.kts index db3990cb72..01153cb857 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,8 @@ include("exposed-plugin-core") include("exposed-gradle-plugin") include("exposed-maven-plugin") include("exposed-version-catalog") +include("exposed-dao-r2dbc") +include("exposed-dao-r2dbc-tests") pluginManagement { repositories {