Skip to content

feat: EXPOSED-819 Exposed R2DBC DAO - #2831

Open
obabichevjb wants to merge 10 commits into
mainfrom
obabichev/r2dbc-dao-5
Open

feat: EXPOSED-819 Exposed R2DBC DAO#2831
obabichevjb wants to merge 10 commits into
mainfrom
obabichev/r2dbc-dao-5

Conversation

@obabichevjb

Copy link
Copy Markdown
Collaborator

No description provided.

@obabichevjb

Copy link
Copy Markdown
Collaborator Author

R2DBC DAO API — Key Differences from JDBC DAO

This is a work-in-progress. The API described below is not final and may change based on feedback.

This document highlights behavioral and structural differences between the JDBC DAO (exposed-dao) and the new R2DBC DAO (exposed-dao-r2dbc). Straightforward renames (e.g. EntityR2dbcEntity, referencedOnreferencedOnSuspend) are omitted — the focus is on things that change how you write code.


1. Relationship properties: val + accessor instead of var + assignment

JDBC relationships are mutable properties — you read and write them directly:

// JDBC
var broker by Broker referencedOn Clients.broker

client.broker                  // read — returns entity
client.broker = newBroker      // write — assignment

R2DBC relationships are val properties that return an accessor object with two operations:

// R2DBC
val broker by Broker referencedOnSuspend Clients.broker

client.broker()                // read — suspend invoke()
client.broker set newBroker    // write — infix set

Why: Kotlin's delegation protocol requires getValue to return the declared property type. Since reading a relationship in R2DBC requires a suspend call, the property can't directly return the entity. Instead it returns an accessor that provides suspend operator fun invoke() for reads and infix fun set(value) for writes.

One-to-many and back-references are read-only — they only have invoke():

// R2DBC
val clients by Client referrersOnSuspend Brokers.broker

broker.clients()               // suspend invoke() → SizedIterable<Client>

Exception — many-to-many: Inner table links use list assignment instead of set:

// R2DBC
val tags by Tag viaSuspend InstrumentTags

instrument.tags()              // read — suspend invoke()
instrument.tags set listOf(tag1, tag2)  // write — infix set

2. Explicit flushCache() required after entity creation

JDBC auto-flushes pending INSERTs when you access entity.id.value:

// JDBC
val broker = Broker.new { name = "Alice" }
broker.id.value  // triggers INSERT, returns generated ID

R2DBC cannot auto-flush because the flush operation is suspend, and EntityID.value is a synchronous property accessor:

// R2DBC
val broker = Broker.new { name = "Alice" }
flushCache()     // explicit — executes the INSERT
broker.id.value  // now safe to read

Forgetting flushCache() before reading the ID will throw or return an uninitialized value.


3. suspend on all I/O methods

Every method that touches the database is suspend in R2DBC. Key examples:

Method JDBC R2DBC
EntityClass.findById(id) fun findById(id): T? suspend fun findById(id): T?
EntityClass.count(op) fun count(op): Long suspend fun count(op): Long
Entity.delete() fun delete() suspend fun delete()
Entity.flush(batch) fun flush(batch): Boolean suspend fun flush(batch): Boolean
Entity.refresh(flush) fun refresh(flush) suspend fun refresh(flush)
EntityClass[id] operator fun get(id): T suspend operator fun get(id): T
EntityClass.reload(entity) open fun reload(entity): T? suspend fun reload(entity): T?
EntityCache.flush() fun flush() suspend fun flush()
EntityCache.clear(flush) fun clear(flush) suspend fun clear(flush)
Transaction.flushCache() fun Transaction.flushCache() suspend fun R2dbcTransaction.flushCache()

Entity hook subscriptions also accept suspend lambdas:

// JDBC
EntityHook.subscribe { change -> /* non-suspend */ }

// R2DBC
EntityHook.subscribe { change -> /* suspend */ }

4. Collections return SizedIterable backed by Flow

R2DBC's SizedIterable is Flow-based rather than Iterable-based. This means collecting results requires an explicit toList() call:

// JDBC
broker.clients.map { it.name }            // direct iteration

// R2DBC
broker.clients().toList().map { it.name }  // collect Flow, then map

This also affects eager loading — R2DBC requires two with() overloads (one for SizedIterable, one for Iterable) where JDBC has one.


5. Explicit attach() for cross-transaction entity reuse

JDBC auto-attaches entities to the current transaction when you set a column value (inside Column.setValue). R2DBC can't do this because the auto-attach would require a suspend call inside a non-suspend operator.

R2DBC provides an explicit attach() method instead:

// R2DBC
suspendTransaction {
    val broker = Broker.findById(id)!!
    // ... later, in a different transaction context:
    Broker.attach(broker)  // explicitly re-attach
}

6. Missing JDBC features (not yet ported)

The following JDBC DAO features have no R2DBC equivalent yet:

  • ImmutableEntityClass / ImmutableCachedEntityClass — immutable entities with cross-transaction caching
  • findWithCacheCondition — cache-first lookup with fallback to DB query
  • warmUpReferences / warmUpOptReferences — bulk eager-loading helpers (R2DBC has equivalent private helpers but issues per-parent queries instead of bulk compoundOr queries for composite FKs)

@HacktheTime

Copy link
Copy Markdown

Last time I asked you said its not usable yet or sth. How would you describe this 1? What stuff should I be aware of?

Also the attachment stuff isn't clear to me yet. I didnt find a good explanation in the doc either in a quick scan.

from the tutorial rn:
can i update them later like this?
val jamesList = suspendTransaction {
UsersTable.selectAll().where { UsersTable.firstName eq "James" }.toList()
}
//some other code
suspendTransaction{
jameslist.first().adress set "examplestreet"
}

What do I need to stay aware of if some fields of a entity could be changed while sth else still has it "cached"?

@HacktheTime

Copy link
Copy Markdown

Also build exposed with team city should skip detect. right now it fails with detekt weighted issues error.

A Seperate detekt pipeline is good though.

@HacktheTime

Copy link
Copy Markdown

settings.gradle.kts is missing a include("exposed-dao-r2dbc") rn

@HacktheTime

Copy link
Copy Markdown

Warning merging is currently not possible I think. After the swap of couroutine version 1.10.2 to 1.11.0 I have gotten a error. Reducing the version to 1.10.2 removes said error.

java.lang.NoSuchMethodError: 'java.lang.Object kotlinx.coroutines.BuildersKt.runBlockingK$default(kotlin.coroutines.CoroutineContext, kotlin.jvm.functions.Function2, int,
java.lang.Object)'
at org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase.connectionMetadata$exposed_r2dbc(R2dbcDatabase.kt:57)
at org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase.identifierManager_delegate$lambda$0(R2dbcDatabase.kt:121)
at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:86)
at org.jetbrains.exposed.v1.r2dbc.R2dbcDatabase.getIdentifierManager(R2dbcDatabase.kt:121)
at org.jetbrains.exposed.v1.core.vendors.DatabaseDialectKt.inProperCase(DatabaseDialect.kt:203)
at org.jetbrains.exposed.v1.r2dbc.vendors.MysqlDialectMetadata.metadataMatchesTable(MysqlDialectMetadata.kt:17)
at org.jetbrains.exposed.v1.r2dbc.vendors.DatabaseDialectMetadata.tableExists(DatabaseDialectMetadata.kt:76)
at org.jetbrains.exposed.v1.r2dbc.vendors.DatabaseDialectMetadata$tableExists$1.invokeSuspend(DatabaseDialectMetadata.kt)
at _COROUTINE.BOUNDARY.(CoroutineDebugging.kt:42)
at org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt.inTopLevelSuspendTransaction(Transactions.kt:190)
at org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt.suspendTransaction(Transactions.kt:136)
at de.hype.bingonet.server.managers.Core.init(Core.kt:133)

@HacktheTime

HacktheTime commented Jul 4, 2026

Copy link
Copy Markdown

obabichevjb I found a major issue it seems.

Bildschirmfoto_20260705_012036 unlike what its saying here at least this is incorrect. Bildschirmfoto_20260705_012437

I decided to avoid attaching issues in the future by using a custom wrapper for all of fields in entity classes similar to how the reference work. Yet I get the following error:

org.jetbrains.exposed.r2dbc.dao.exceptions.R2dbcEntityNotFoundException: Entity BBUser, id=1 not found in the database
at org.jetbrains.exposed.r2dbc.dao.R2dbcEntityClass.invalidateEntityInCache$exposed_dao_r2dbc(R2dbcEntityClass.kt:136)
at org.jetbrains.exposed.r2dbc.dao.R2dbcEntity.setValue(R2dbcEntity.kt:61)
at de.hype.bingonet.server.extensionutils.AttachHandle$set$2.invokeSuspend(ExposedAttachmentUtils.kt:52)

After some more investigation there are seemingly 2 entitys with the same id but different locations so the === says false while the attatch returns early since it thinks its already in the cache.

R2dbcEntityClass.kt:153 checks if in the cache but only some entity
R2dbcEntityCache:102 checks if the cache has this exact same entity as the registered

given the top description this seem incorrect. for the bottom thrown exception I would also say that a more detailed exception would fit better since the not found is a bit misleading.

@obabichevjb

Copy link
Copy Markdown
Collaborator Author

JDBC DAO → R2DBC DAO: Public API Diff

A class-by-class comparison of public members only. Excluded:

  • internal, protected, private members.
  • The suspend modifier (every database-touching method is suspend in R2DBC).

Entity<ID>

Public member JDBC R2DBC Notes
val id: EntityID<ID> Constructor parameter.
var klass: EntityClass<ID, Entity<ID>> internal set in both.
var db Database R2dbcDatabase Type changed.
val writeValues Same.
var _readValues Same.
val readValues Auto-fetches from DB on first miss Throws if _readValues == null Semantic change. R2DBC's getter cannot suspend.
fun refresh(flush: Boolean = false) Same signature.
fun delete() R2DBC also skips DELETE SQL when entity was not flushed yet.
fun flush(batch: EntityBatchUpdate? = null): Boolean Same.
fun storeWrittenValues() R2DBC drops the ColumnWithTransform.unwrapRecursive branch — minor logic diff.
operator fun Column<T>.getValue/setValue Same.
operator fun CompositeColumn<T>.getValue/setValue Same.
operator fun EntityFieldWithTransform.getValue/setValue Same.
operator fun Reference.getValue/setValue REMOVED Replaced by Accessor pattern in relationships/.
operator fun OptionalReference.getValue/setValue REMOVED Replaced by OptionalAccessor.
fun Column<T>.lookup(): T R2DBC throws on isDatabaseGenerated() columns before flush; JDBC silently auto-flushes via the same path.
fun Column<T>.lookupInReadValues(found, notFound) REMOVED Not ported.
infix fun via(table) Same.
fun via(sourceColumn, targetColumn) Same.

EntityClass<ID, T>

Public member JDBC R2DBC Notes
fun all() Same.
fun find(op) / fun find(op: () -> Op<Boolean>) Same.
fun findById(id) (both ID and EntityID<ID> overloads) Same.
fun findByIdAndUpdate / findSingleByAndUpdate Same.
fun count(op) Same.
fun searchQuery(op) Same.
fun wrap(entityID, row) / wrapRow / wrapRows Same.
fun reload(entity, flush) Same.
fun removeFromCache(entity) Same.
fun forEntityIds(ids) Same.
fun testCache(id) / fun testCache(cacheCheckCondition) Same.
fun new(init): T Suspend in R2DBC; eagerly flushes and returns T (matches JDBC semantics).
fun new(id, init): T Suspend in R2DBC; same semantics.
fun newDeferred(init): Flow<T> R2DBC-only. Non-suspend; schedules the insert and flushes the entire cache on first collection. For batching / graph-build.
fun newDeferred(id, init): Flow<T> R2DBC-only.
fun attach(entity) R2DBC-only.
fun forIds(ids: List<ID>): SizedIterable<T> Ported.
fun view(op: () -> Op<Boolean>): View<T> Missing (View class isn't ported either).
fun expireCache() Missing. Tied to ImmutableCachedEntityClass.
fun findWithCacheCondition(cond, op) Missing. Tied to ImmutableCachedEntityClass.
fun warmUpReferences / warmUpOptReferences / warmUpLinkedReferences Same public surface.
infix fun referencedOn / optionalReferencedOn / referrersOn / optionalReferrersOn / backReferencedOn / optionalBackReferencedOn Member on EntityClass Member on EntityClass Same location and same call-site syntax; return types differ (see relationships section).
infix fun via(table) / fun via(sourceColumn, targetColumn) Member on Entity (only via Entity.via) Same place Same.
Operator get(id) Same.

EntityCache

Public member JDBC R2DBC Notes
val data: Map<IdTable<*>, ...> Same.
fun find(klass, id) Same.
fun store(entity) / fun store(klass, entity) Same.
fun remove(table, entity) Same.
fun findAll(klass) Same.
fun scheduleInsert(klass, entity) Same.
fun scheduleUpdate(klass, entity) Same.
fun getOrPutReferrers(sourceId, key, refs) Same parameter order/naming.
fun getReferrers(sourceId, key) Same.
fun clear(flush) Same.
fun clearReferrersCache() Same.
fun updateEntities(table) Same.
fun flush() / fun flush(tables) Same.
var maxEntitiesToStore Same.
companion object: fun invalidateGlobalCaches(created) Missing. Tied to ImmutableCachedEntityClass.

EntityHook / EntityChange

Same public API in both modules — subscribe, unsubscribe, EntityChange(EntityClass, EntityID, EntityChangeType). R2DBC has its own copy because the lifecycle is owned by R2DBC's EntityClass.


Relationships layer

Setup-time DSL (member functions on EntityClass, matches JDBC layout)

JDBC R2DBC Status
infix fun referencedOn(column) infix fun referencedOn(column) Same name, but returned Reference is based on Accessor
infix fun optionalReferencedOn(column) same Same.
infix fun referrersOn(column) / referrersOn(column, cache) same Same name; return type changed.
infix fun optionalReferrersOn(column) / optionalReferrersOn(column, cache) same Same.
infix fun backReferencedOn(column) same Same.
infix fun optionalBackReferencedOn(column) same Same.
infix fun via(table) (on Entity) same Same.

Runtime read/write API

Operation JDBC R2DBC
Read many-to-one entity.parent (property access) entity.parent() (suspend invoke)
Write many-to-one entity.parent = x entity.parent.set(x) or entity.parent(x)
Read one-to-many entity.children returns SizedIterable<Child> entity.children returns a DeferredQuery<Child> (implements SizedIterable<Child>, Flow-based, produced by Referrers.getValue)
Read back-reference entity.backRef entity.backRef() (suspend invoke)
Read many-to-many entity.tags returns SizedIterable<Tag> entity.tags returns InnerTableLinkAccessor (Flow-based SizedIterable<Tag>)
Write many-to-many entity.tags = SizedCollection(...) Same syntax — deferred until flush in R2DBC
Eager load query.with(Entity::prop) query.with(Entity::prop) (suspend)

Removed exported classes

JDBC Replacement in R2DBC
Reference<REF, RID, T> Reference<ID, Parent, REF> + runtime Accessor<ID, Parent, REF>
OptionalReference<REF, RID, T> OptionalReference<ID, Parent, REF> + runtime OptionalAccessor<ID, Parent, REF>
Referrers<ParentID, Parent, ChildID, Child, REF> Referrers<ParentID, Parent, ChildID, Child, REF> — setup class; getValue returns a DeferredQuery<Child> (no dedicated runtime accessor)
BackReference<...> BackReference<...> (same class name, different runtime API)
InnerTableLink<...> (also acts as runtime accessor) Split into InnerTableLink (setup) and InnerTableLinkAccessor (runtime, delegates SizedIterable to DeferredQuery via Kotlin's by)

Missing classes (JDBC has them; R2DBC doesn't)

JDBC class Note
ImmutableEntityClass Read-only entity marker.
ImmutableCachedEntityClass Process-wide read cache.
View<T> Filtered entity view as SizedIterable.
DaoEntityIDFactory EntityID factory registration.

New classes (R2DBC has them; JDBC doesn't)

R2DBC class Purpose
Accessor / OptionalAccessor Runtime many-to-one accessor (suspend invoke, set, invoke(value)).
InnerTableLinkAccessor Runtime many-to-many delegate; SizedIterable behavior delegated to DeferredQuery via Kotlin's by.
DeferredQuery (internal) Lazy SizedIterable used by Referrers.getValue and InnerTableLinkAccessor to defer query execution.
ExperimentalR2dbcDaoApi Opt-in annotation gating the public R2DBC DAO API surface.

@obabichevjb

Copy link
Copy Markdown
Collaborator Author

EntityClass.new { } API choice for R2DBC DAO

TL;DRnew { } is suspend and returns T (matches JDBC). A separate
newDeferred { } returns Flow<T> for the rare batching / graph-build case. It must not be the final solution, but looks much better comparing to the previous one.

Surveyed 30+ open-source Kotlin projects using exposed-dao (Ktor apps, Spring Boot
apps, Discord bots, ort-server, kotlin-libraries-playground, docs
examples, etc.)

Distribution:

Pattern Share What the code reads from the new entity
A — chain to mapper/DTO: Foo.new { … }.toResponse() ~30% ID + generated timestamps
B — read .id.value immediately ~15% ID
C — return entity to caller ~20% Caller reads ID + audit
D — capture as val, reuse as FK in same tx ~25% ID (as FK for more .new { })
E — fire-and-forget ~10% Nothing

~90% of call sites need the persisted entity or its ID immediately.

If new { } returned a builder requiring .flush(), idioms like
Foo.new { … }.id.value, Foo.new { … }.mapToModel(),
val parent = Parent.new { … }; Child.new { this.parent = parent }
would all break on every single write. That's a lot of friction on the migration
path from JDBC DAO.

newDeferred { } — the ~10% escape hatch

For the graph-build / batch-insert case (e.g. bulk-loading a parent + children in
one transaction), newDeferred { } returns Flow<T>. It schedules without SQL;
collecting the flow triggers one cache flush that batch-INSERTs everything.

Creating parent-child entities in memory

At the current moment it's not possible to create some entities connected via references in memory (without flushing to the database. It could be good idea to implement that case too, if we choose the current way. But at the current moment in terms of communication with database it matches jdbc. Under the hood jdbc also makes flush (if I understood everything correct) in the moment, when the referenced entity set to another entity.

@bog-walk Chantal Loncle (bog-walk) changed the title feat: Exposed R2DBC DAO feat: EXPOSED-819 Exposed R2DBC DAO Jul 16, 2026
obabichevjb and others added 8 commits July 28, 2026 10:06
* feat: eager loading, many-to-many

* feat: Complete migration of EntityTests

* feat: Extract trimToFirst
feat: Change new() method, now it is suspend. Alternative newDeferred() returns Flow of the Entity.

feat: Fixes after review. DefferedQuery to work with collections on relations; Container for initialized entities
…BC-to-R2DBC migration docs and standalone showcase build
@obabichevjb
obabichevjb force-pushed the obabichev/r2dbc-dao-5 branch from 4d57a04 to 252a8cf Compare July 31, 2026 09:36
@HacktheTime

Copy link
Copy Markdown

Tried the things again.

Still some issue it seems:

12:21:48.536 [reactor-tcp-epoll-4 Coroutine LLC (@coroutine)#63] ERROR de.hype.bingonet.server.BBLogger - Entity BingoEventAPIPlayerData, id=CompositeID(bingo_id=55, mc_uuid=1cedf17e-d9b0-47d3-a90a-92611306c44f) not found in the database
org.jetbrains.exposed.v1.dao.r2dbc.exceptions.EntityNotFoundException: Entity BingoEventAPIPlayerData, id=CompositeID(bingo_id=55, mc_uuid=1cedf17e-d9b0-47d3-a90a-92611306c44f) not found in the database
at org.jetbrains.exposed.v1.dao.r2dbc.EntityClass.invalidateEntityInCache$exposed_dao_r2dbc(EntityClass.kt:313)
at org.jetbrains.exposed.v1.dao.r2dbc.Entity.setValue(Entity.kt:81)
at de.hype.bingonet.server.extensionutils.AttachHandle.set(ExposedAttachmentUtils.kt:47)
at de.hype.bingonet.data.tables.apidata.BingoEventAPIPlayerData.completedCard(APITables.kt:273)
at de.hype.bingonet.website.featurepages.LookupPageController$lookup$2$1.invokeSuspend(LookupPageController.kt:195)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith$$$capture(ContinuationImpl.kt:34)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)
at kotlinx.coroutines.UndispatchedCoroutine.afterResume(CoroutineContext.kt:266)

@HacktheTime

HacktheTime commented Jul 31, 2026

Copy link
Copy Markdown

obabichevjb I had some patches previously to get it running and i afterwards merged in the changes. I laso tried to fix the issue myself so maybe sth went wrong there. I just did it base on top of your current branch and I dont get that error at least. But its not working either. Ill investigate further

@HacktheTime

HacktheTime commented Jul 31, 2026

Copy link
Copy Markdown

I noticed an freeze. Its not validated to come from your code yet though

When i ran runBlocking { suspendTransaction { BBRoleEntity.find { BBRoles.user eq this@BBUser }.toList() }} in debugger it never returned. result said collection data for more than 2 minutes. I noticed this since there was an freeze Issue during my test somewhere. so its not just debugger.

class BBRoleEntity(id: EntityID<CompositeID>) : CompositeEntity(id) {
    companion object : CompositeEntityClass<BBRoleEntity>(BBRoles)

    val user by BBRoles.user.transformFlat().selfAttaching(Companion)
    val role by BBRoles.role.transformFlat().selfAttaching(Companion)
}

object BBRoles : CompositeIdTable("user_roles") {
    // user is stored using the project's bbUser column type and used as part of the composite id
    val user = bbUser("user").entityId()
    // role is stored as enum-by-name
    val role = enumerationByName<BNRole>("role").entityId()
    init {
        addIdColumn(user)
        addIdColumn(role)
    }
}

fun <T : Any> Column<EntityID<T>>.transformFlat(
    table: IdTable<T> = this.table as IdTable<T>,
    cacheResult: Boolean = false
): EntityFieldWithTransform<EntityID<T>, T> = EntityFieldWithTransform(
    column = this,
    transformer = columnTransformer(
        {
            object : EntityID<T>(table, it) {}
        }, {
            it.value
        }
    ),
    cacheResult = cacheResult
)

context(it: Entity<ID>)
fun <ID : Any, E : Entity<ID>, CT : Any?> Column<CT>.selfAttaching(
    entityClass: EntityClass<ID, E>
): AttachDelegate<ID, E, CT> = AttachDelegate(this, entityClass)

fun <WrappedType, DatabaseType, SuperEntityId : Any> EntityFieldWithTransform<DatabaseType, WrappedType>.selfAttaching(
    entityClass: EntityClass<SuperEntityId, Entity<SuperEntityId>>
): AttachWrappedDelegate<WrappedType, DatabaseType, SuperEntityId> =
    AttachWrappedDelegate(this, entityClass)

abstract class AttatchHandler<Type> {
    abstract fun get(): Type
    abstract suspend infix fun set(value: Type)
    operator fun invoke(): Type = get()

    override fun equals(other: Any?): Boolean {
        return other == get()
    }

    override fun hashCode(): Int {
        return get().hashCode()
    }

    override fun toString(): String {
        return get().toString()
    }
}

class AttachHandle<ID : Any, E : Entity<ID>, CT : Any?>(
    private val column: Column<CT>,
    private val entity: E,
    private val entityClass: EntityClass<ID, *>,
    private val prop: KProperty<*>
) : AttatchHandler<CT>() {
    override fun get(): CT = with(entity) { column.getValue(entity, prop) }

    override suspend infix fun set(value: CT) {
        entityClass.attach(entity)
        with(entity) { column.setValue(entity, prop, value) }
    }
}

class AttachWrappedHandle<WrappedType, DatabaseType, SuperEntityId : Any>(
    private val column: EntityFieldWithTransform<DatabaseType, WrappedType>,
    private val entity: Entity<SuperEntityId>,
    private val entityClass: EntityClass<SuperEntityId, *>,
    private val prop: KProperty<*>
) : AttatchHandler<WrappedType>() {
    override fun get(): WrappedType = with(entity) {
        column.getValue(entity, prop)
    }

    override suspend infix fun set(value: WrappedType) {
        entityClass.attach(entity)
        with(entity) { column.setValue(entity, prop, value) }
    }

}

class AttachDelegate<ID : Any, E : Entity<ID>, CT : Any?>(
    private val column: Column<CT>,
    private val entityClass: EntityClass<ID, E>
) {
    operator fun getValue(thisRef: E, property: KProperty<*>): AttachHandle<ID, E, CT> {
        @Suppress("UNCHECKED_CAST")
        return AttachHandle(column, thisRef, entityClass as EntityClass<ID, *>, property)
    }
}


class AttachWrappedDelegate<WrappedType, DatabaseType, SuperEntityId : Any>(
    private val column: EntityFieldWithTransform<DatabaseType, WrappedType>,
    private val entityClass: EntityClass<SuperEntityId, Entity<SuperEntityId>>
) {

    operator fun getValue(
        thisRef: Entity<SuperEntityId>,
        property: KProperty<*>
    ): AttachWrappedHandle<WrappedType, DatabaseType, SuperEntityId> {
        @Suppress("UNCHECKED_CAST")
        return AttachWrappedHandle(column, thisRef, entityClass, property)
    }
}

suspend operator fun <T : Number> AttatchHandler<T>.plusAssign(value: T) {
    val result = when (value) {
        is Int -> (get().toInt() + value.toInt())
        is Long -> (get().toLong() + value.toLong())
        is Double -> (get().toDouble() + value.toDouble())
        is Float -> (get().toFloat() + value.toFloat())
        else -> error("Unsupported numeric type: ${value::class}")
    }
    set(result as T)
}
suspend operator fun <T : Number> AttatchHandler<T>.minusAssign(value: T) {
    val result = when (value) {
        is Int -> (get().toInt() - value.toInt())
        is Long -> (get().toLong() - value.toLong())
        is Double -> (get().toDouble() - value.toDouble())
        is Float -> (get().toFloat() - value.toFloat())
        else -> error("Unsupported numeric type: ${value::class}")
    }
    set(result as T)
}
suspend operator fun <T : Number> AttatchHandler<T>.divAssign(value: T) {
    val result = when (value) {
        is Int -> (get().toInt() / value.toInt())
        is Long -> (get().toLong() / value.toLong())
        is Double -> (get().toDouble() / value.toDouble())
        is Float -> (get().toFloat() / value.toFloat())
        else -> error("Unsupported numeric type: ${value::class}")
    }
    set(result as T)
}
suspend operator fun <T : Number> AttatchHandler<T>.timesAssign(value: T) {
    val result = when (value) {
        is Int -> (get().toInt() * value.toInt())
        is Long -> (get().toLong() * value.toLong())
        is Double -> (get().toDouble() * value.toDouble())
        is Float -> (get().toFloat() * value.toFloat())
        else -> error("Unsupported numeric type: ${value::class}")
    }
    set(result as T)
}

… entity which is already modified in the current cache.
@HacktheTime

Copy link
Copy Markdown

Ill test it shortly. But what exactly does this mean? Like do I need to refresh it all the time before I can use it or sth?

@obabichevjb

Copy link
Copy Markdown
Collaborator Author

HacktheTime Thank you for rising the issues, I really appreciate it. It's the first version of the module and I expect that there are many edge cases which are not covered in the code yet.

I understood the problem with attach(). It was ignoring attaching if the entity is already in the cache, so the entity was not actually attaching.

But from my perspective we should not be able to attach the entity if it's already in the cache and was modified, because in this case we will silently loose updates. So I extended attach() method, so it throws error in such case. And also added force argument (attach(entity, force = false)) which allows to reattach entity even if it's already in the cache and has updates.

@HacktheTime

Copy link
Copy Markdown

HacktheTime Thank you for rising the issues, I really appreciate it. It's the first version of the module and I expect that there are many edge cases which are not covered in the code yet.

I understood the problem with attach(). It was ignoring attaching if the entity is already in the cache, so the entity was not actually attaching.

But from my perspective we should not be able to attach the entity if it's already in the cache and was modified, because in this case we will silently loose updates. So I extended attach() method, so it throws error in such case. And also added force argument (attach(entity, force = false)) which allows to reattach entity even if it's already in the cache and has updates.

I think I have a good way to rephrase my question and make it more specific.

Attaching is supposed to make the entity reuseable in another transaction right?

But if I cant reuse it what is the point of attach or sth? Is attach essentially a helper for bad design that now just has stricter limits? Essentially an may cause Issues in some cases and bad design but im will add something with which you can do it anyway? And my usecase might be limitated with that so its an issue with my code stylewise already?

Also I think that freeze Issue still persists. I am trying to make a reproduceable test in just exposed codebase but havent been successful just yet.

Also for future development. I think it would be good to in the future make the ongoing coding jitpack compatible. That way testing is easier and to avoid merge issues etc as a whole. (If I remeber the sources dont get generated correctly by it either)

@HacktheTime

HacktheTime commented Jul 31, 2026

Copy link
Copy Markdown

I have finally found a trick to have a look at the issue. But I wasnt able to make a test for it really.
→ overall its related to unconventional use of the columntypes. Ill look into it further later today / tomorrow

Main stacktrace:
parkNanos:271, LockSupport (java.util.concurrent.locks)
get$lambda$0:56, ResultRow (org.jetbrains.exposed.v1.core)
onNext:109, FluxContextWrite$ContextWriteSubscriber (reactor.core.publisher)
runBlocking$default:48, BuildersKt__BuildersKt (kotlinx.coroutines)
drain:887, FluxCreate$BufferAsyncSink (reactor.core.publisher)
resumeWith$$$capture:34, BaseContinuationImpl (kotlin.coroutines.jvm.internal)
invoke:55, CompositeID$Companion (org.jetbrains.exposed.v1.core.dao.id)
run:30, FastThreadLocalRunnable (io.netty.util.concurrent)
loadedResult:170, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
run:74, ThreadExecutorMap$2 (io.netty.util.internal)
get:54, ResultRow (org.jetbrains.exposed.v1.core)
invokeSuspend:194, TransactionsKt$inTopLevelSuspendTransaction$2 (org.jetbrains.exposed.v1.r2dbc.transactions)
onNext:86, SubscriptionChannel (kotlinx.coroutines.reactive)
withDialect:178, DatabaseDialectKt (org.jetbrains.exposed.v1.core.vendors)
drain:757, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
handle:349, EpollIoHandler$DefaultEpollIoRegistration (io.netty.channel.epoll)
cached:295, ResultRow$ResultRowCache (org.jetbrains.exposed.v1.core)
fireChannelRead:918, DefaultChannelPipeline (io.netty.channel)
onInboundNext:407, FluxReceive (reactor.netty.channel)
dispatch:147, DispatchedTaskKt (kotlinx.coroutines)
collect:226, AbstractFlow (kotlinx.coroutines.flow)
run:1474, Thread (java.lang)
onNext:799, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
access$updateCellSend:33, BufferedChannel (kotlinx.coroutines.channels)
valueFromDB:243, EntityIDColumnType (org.jetbrains.exposed.v1.core)
tryResumeHasNext:1719, BufferedChannel$BufferedChannelIterator (kotlinx.coroutines.channels)
getInternal$lambda$0$0$0:114, ResultRow (org.jetbrains.exposed.v1.core)
suspendTransaction:136, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
next:164, FluxCreate$SerializedFluxSink (reactor.core.publisher)
runBlocking$default:1, BuildersKt (kotlinx.coroutines)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
updateCellSend:478, BufferedChannel (kotlinx.coroutines.channels)
epollInReady:804, AbstractEpollStreamChannel$EpollStreamUnsafe (io.netty.channel.epoll)
emit:113, SafeCollector (kotlinx.coroutines.flow.internal)
runIo:225, SingleThreadIoEventLoop (io.netty.channel)
resumeUnconfined:175, DispatchedTaskKt (kotlinx.coroutines)
run:491, EpollIoHandler (io.netty.channel.epoll)
onInboundNext:447, ChannelOperations (reactor.netty.channel)
onNext:80, FluxOnErrorResume$ResumeSubscriber (reactor.core.publisher)
valueFromDB:18, BBUserColumnType (de.hype.bingonet.data.utils)
trySend-JP2dKIU:301, BufferedChannel (kotlinx.coroutines.channels)
processReady:546, EpollIoHandler (io.netty.channel.epoll)
channelRead:1429, DefaultChannelPipeline$HeadContext (io.netty.channel)
next:812, FluxCreate$BufferAsyncSink (reactor.core.publisher)
onNext:122, FluxMap$MapSubscriber (reactor.core.publisher)
runBlocking:70, BuildersKt__BuildersKt (kotlinx.coroutines)
inTopLevelSuspendTransaction:190, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
joinBlocking:97, BlockingCoroutine (kotlinx.coroutines)
emit:82, SafeCollector (kotlinx.coroutines.flow.internal)
drainRegular:679, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
tryResume0:2957, BufferedChannelKt (kotlinx.coroutines.channels)
completeResume:591, CancellableContinuationImpl (kotlinx.coroutines)
fireChannelRead:361, ByteToMessageDecoder (io.netty.handler.codec)
runBlocking:1, BuildersKt (kotlinx.coroutines)
handle:487, AbstractEpollChannel$AbstractEpollUnsafe (io.netty.channel.epoll)
invoke:11, SafeCollectorKt$emitFun$1 (kotlinx.coroutines.flow.internal)
emit:66, Exchange (org.mariadb.r2dbc.client)
onNext:197, FluxHandleFuseable$HandleFuseableSubscriber (reactor.core.publisher)
dispatchResume:470, CancellableContinuationImpl (kotlinx.coroutines)
wrapRows$lambda$0:409, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
invokeSuspend:275, LookupPageController$lookup$2 (de.hype.bingonet.website.featurepages)
fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)
tryResumeReceiver:662, BufferedChannel (kotlinx.coroutines.channels)
channelRead:325, ByteToMessageDecoder (io.netty.handler.codec)
invokeSuspend:52, R2dbcResult$mapRows$1 (org.jetbrains.exposed.v1.r2dbc.statements.api)
access$tryResume0:1, BufferedChannelKt (kotlinx.coroutines.channels)
collect$suspendImpl:326, Query (org.jetbrains.exposed.v1.r2dbc)
valueFromDB:262, EntityIDColumnType (org.jetbrains.exposed.v1.core)
run:1195, SingleThreadEventExecutor$5 (io.netty.util.concurrent)
drainReceiver:296, FluxReceive (reactor.netty.channel)
getRoles:146, BBUser (de.hype.bingonet.server.objects)
emit:170, IterableExKt$mapLazy$1$loadedResult$$inlined$map$1$2 (org.jetbrains.exposed.v1.r2dbc)
resume:237, DispatchedTaskKt (kotlinx.coroutines)
collect:183, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
channelRead:115, ChannelOperationsHandler (reactor.netty.channel)
collect:47, BBUser$getRoles$$inlined$map$1 (de.hype.bingonet.server.objects)
onNext:718, SimpleClient$ServerMessageSubscriber (org.mariadb.r2dbc.client)
run:196, SingleThreadIoEventLoop (io.netty.channel)
onNext:91, StrictSubscriber (reactor.core.publisher)
wrapRow:425, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
runWith:1487, Thread (java.lang)
onNext:273, FluxWindowPredicate$WindowPredicateMain (reactor.core.publisher)
fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
getInternal$lambda$0:113, ResultRow (org.jetbrains.exposed.v1.core)
invokeSuspend:52, R2dbcResult$mapRows$1 (org.jetbrains.exposed.v1.r2dbc.statements.api)
valueFromDB:9, BBUserColumnType (de.hype.bingonet.data.utils)
getInternal:100, ResultRow (org.jetbrains.exposed.v1.core)
onNext:778, SimpleClient$ServerMessageSubscriber (org.mariadb.r2dbc.client)
rawToColumnValue:126, ResultRow (org.jetbrains.exposed.v1.core)

Extra (after main) (connected via BBUser.fromUserId from main)
withConnection:231, R2dbcConnectionImpl (org.jetbrains.exposed.v1.r2dbc.statements)
collect:183, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
prepared$suspendImpl:61, SuspendExecutable (org.jetbrains.exposed.v1.r2dbc.statements)
invokeSusMain stacktrace:
parkNanos:271, LockSupport (java.util.concurrent.locks)
get$lambda$0:56, ResultRow (org.jetbrains.exposed.v1.core)
onNext:109, FluxContextWrite$ContextWriteSubscriber (reactor.core.publisher)
runBlocking$default:48, BuildersKt__BuildersKt (kotlinx.coroutines)
drain:887, FluxCreate$BufferAsyncSink (reactor.core.publisher)
resumeWith$$$capture:34, BaseContinuationImpl (kotlin.coroutines.jvm.internal)
invoke:55, CompositeID$Companion (org.jetbrains.exposed.v1.core.dao.id)
run:30, FastThreadLocalRunnable (io.netty.util.concurrent)
loadedResult:170, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
run:74, ThreadExecutorMap$2 (io.netty.util.internal)
get:54, ResultRow (org.jetbrains.exposed.v1.core)
invokeSuspend:194, TransactionsKt$inTopLevelSuspendTransaction$2 (org.jetbrains.exposed.v1.r2dbc.transactions)
onNext:86, SubscriptionChannel (kotlinx.coroutines.reactive)
withDialect:178, DatabaseDialectKt (org.jetbrains.exposed.v1.core.vendors)
drain:757, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
handle:349, EpollIoHandler$DefaultEpollIoRegistration (io.netty.channel.epoll)
cached:295, ResultRow$ResultRowCache (org.jetbrains.exposed.v1.core)
fireChannelRead:918, DefaultChannelPipeline (io.netty.channel)
onInboundNext:407, FluxReceive (reactor.netty.channel)
dispatch:147, DispatchedTaskKt (kotlinx.coroutines)
collect:226, AbstractFlow (kotlinx.coroutines.flow)
run:1474, Thread (java.lang)
onNext:799, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
access$updateCellSend:33, BufferedChannel (kotlinx.coroutines.channels)
valueFromDB:243, EntityIDColumnType (org.jetbrains.exposed.v1.core)
tryResumeHasNext:1719, BufferedChannel$BufferedChannelIterator (kotlinx.coroutines.channels)
getInternal$lambda$0$0$0:114, ResultRow (org.jetbrains.exposed.v1.core)
suspendTransaction:136, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
next:164, FluxCreate$SerializedFluxSink (reactor.core.publisher)
runBlocking$default:1, BuildersKt (kotlinx.coroutines)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
updateCellSend:478, BufferedChannel (kotlinx.coroutines.channels)
epollInReady:804, AbstractEpollStreamChannel$EpollStreamUnsafe (io.netty.channel.epoll)
emit:113, SafeCollector (kotlinx.coroutines.flow.internal)
runIo:225, SingleThreadIoEventLoop (io.netty.channel)
resumeUnconfined:175, DispatchedTaskKt (kotlinx.coroutines)
run:491, EpollIoHandler (io.netty.channel.epoll)
onInboundNext:447, ChannelOperations (reactor.netty.channel)
onNext:80, FluxOnErrorResume$ResumeSubscriber (reactor.core.publisher)
valueFromDB:18, BBUserColumnType (de.hype.bingonet.data.utils)
trySend-JP2dKIU:301, BufferedChannel (kotlinx.coroutines.channels)
processReady:546, EpollIoHandler (io.netty.channel.epoll)
channelRead:1429, DefaultChannelPipeline$HeadContext (io.netty.channel)
next:812, FluxCreate$BufferAsyncSink (reactor.core.publisher)
onNext:122, FluxMap$MapSubscriber (reactor.core.publisher)
runBlocking:70, BuildersKt__BuildersKt (kotlinx.coroutines)
inTopLevelSuspendTransaction:190, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
joinBlocking:97, BlockingCoroutine (kotlinx.coroutines)
emit:82, SafeCollector (kotlinx.coroutines.flow.internal)
drainRegular:679, FluxWindowPredicate$WindowFlux (reactor.core.publisher)
tryResume0:2957, BufferedChannelKt (kotlinx.coroutines.channels)
completeResume:591, CancellableContinuationImpl (kotlinx.coroutines)
fireChannelRead:361, ByteToMessageDecoder (io.netty.handler.codec)
runBlocking:1, BuildersKt (kotlinx.coroutines)
handle:487, AbstractEpollChannel$AbstractEpollUnsafe (io.netty.channel.epoll)
invoke:11, SafeCollectorKt$emitFun$1 (kotlinx.coroutines.flow.internal)
emit:66, Exchange (org.mariadb.r2dbc.client)
onNext:197, FluxHandleFuseable$HandleFuseableSubscriber (reactor.core.publisher)
dispatchResume:470, CancellableContinuationImpl (kotlinx.coroutines)
wrapRows$lambda$0:409, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
invokeSuspend:275, LookupPageController$lookup$2 (de.hype.bingonet.website.featurepages)
fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)
tryResumeReceiver:662, BufferedChannel (kotlinx.coroutines.channels)
channelRead:325, ByteToMessageDecoder (io.netty.handler.codec)
invokeSuspend:52, R2dbcResult$mapRows$1 (org.jetbrains.exposed.v1.r2dbc.statements.api)
access$tryResume0:1, BufferedChannelKt (kotlinx.coroutines.channels)
collect$suspendImpl:326, Query (org.jetbrains.exposed.v1.r2dbc)
valueFromDB:262, EntityIDColumnType (org.jetbrains.exposed.v1.core)
run:1195, SingleThreadEventExecutor$5 (io.netty.util.concurrent)
drainReceiver:296, FluxReceive (reactor.netty.channel)
getRoles:146, BBUser (de.hype.bingonet.server.objects)
emit:170, IterableExKt$mapLazy$1$loadedResult$$inlined$map$1$2 (org.jetbrains.exposed.v1.r2dbc)
resume:237, DispatchedTaskKt (kotlinx.coroutines)
collect:183, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
channelRead:115, ChannelOperationsHandler (reactor.netty.channel)
collect:47, BBUser$getRoles$$inlined$map$1 (de.hype.bingonet.server.objects)
onNext:718, SimpleClient$ServerMessageSubscriber (org.mariadb.r2dbc.client)
run:196, SingleThreadIoEventLoop (io.netty.channel)
onNext:91, StrictSubscriber (reactor.core.publisher)
wrapRow:425, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
runWith:1487, Thread (java.lang)
onNext:273, FluxWindowPredicate$WindowPredicateMain (reactor.core.publisher)
fireChannelRead:357, AbstractChannelHandlerContext (io.netty.channel)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
getInternal$lambda$0:113, ResultRow (org.jetbrains.exposed.v1.core)
invokeSuspend:52, R2dbcResult$mapRows$1 (org.jetbrains.exposed.v1.r2dbc.statements.api)
valueFromDB:9, BBUserColumnType (de.hype.bingonet.data.utils)
getInternal:100, ResultRow (org.jetbrains.exposed.v1.core)
onNext:778, SimpleClient$ServerMessageSubscriber (org.mariadb.r2dbc.client)
rawToColumnValue:126, ResultRow (org.jetbrains.exposed.v1.core)

Extra (after main) (connected via BBUser.fromUserId from main in the BBUserType get())
withConnection:231, R2dbcConnectionImpl (org.jetbrains.exposed.v1.r2dbc.statements)
collect:183, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
toCollection:22, FlowKt__CollectionKt (kotlinx.coroutines.flow)
prepared$suspendImpl:61, SuspendExecutable (org.jetbrains.exposed.v1.r2dbc.statements)
invokeSuspend:194, TransactionsKt$inTopLevelSuspendTransaction$2 (org.jetbrains.exposed.v1.r2dbc.transactions)
executeIn:138, SuspendExecutableKt (org.jetbrains.exposed.v1.r2dbc.statements)
collect$suspendImpl:319, Query (org.jetbrains.exposed.v1.r2dbc)
invokeSuspend:792, BBUser$Companion$fromUserId$2 (de.hype.bingonet.server.objects)
loadedResult:170, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
execQuery$exposed_r2dbc:316, R2dbcTransaction (org.jetbrains.exposed.v1.r2dbc)
firstOrNull:179, FlowKt__ReduceKt (kotlinx.coroutines.flow)
invokeSuspend:258, R2dbcTransaction$exec$8 (org.jetbrains.exposed.v1.r2dbc)
get:507, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
inTopLevelSuspendTransaction:190, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
suspendTransaction:136, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
invokeSuspend:18, BBUserColumnType$valueFromDB$1 (de.hype.bingonet.data.utils)pend:194, TransactionsKt$inTopLevelSuspendTransaction$2 (org.jetbrains.exposed.v1.r2dbc.transactions)
executeIn:138, SuspendExecutableKt (org.jetbrains.exposed.v1.r2dbc.statements)
collect$suspendImpl:319, Query (org.jetbrains.exposed.v1.r2dbc)
invokeSuspend:792, BBUser$Companion$fromUserId$2 (de.hype.bingonet.server.objects)
loadedResult:170, IterableExKt$mapLazy$1 (org.jetbrains.exposed.v1.r2dbc)
execQuery$exposed_r2dbc:316, R2dbcTransaction (org.jetbrains.exposed.v1.r2dbc)
firstOrNull:179, FlowKt__ReduceKt (kotlinx.coroutines.flow)
invokeSuspend:258, R2dbcTransaction$exec$8 (org.jetbrains.exposed.v1.r2dbc)
get:507, EntityClass (org.jetbrains.exposed.v1.dao.r2dbc)
inTopLevelSuspendTransaction:190, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
suspendTransaction:136, TransactionsKt (org.jetbrains.exposed.v1.r2dbc.transactions)
invokeSuspend:18, BBUserColumnType$valueFromDB$1 (de.hype.bingonet.data.utils)

@e5l Leonid Stashevsky (e5l) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

left some comments, please check them out.

I'm still reviewing the core part of the PR

@@ -1,7 +1,5 @@
distributionBase=GRADLE_USER_HOME

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we have a separate exposed-r2dbc-dao-sample?

@@ -0,0 +1,129 @@
package org.jetbrains.exposed.v1.dao.r2dbc.tests.shared

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing Test suffix in the file name and class name

import org.jetbrains.exposed.v1.dao.r2dbc.ExperimentalR2dbcDaoApi

/**
* An exception that provides information about an [entity] that could not be accessed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please consider simplify the KDoc

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. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please consider dropping code explaining KDocs. The documentation for these classes supposed to explain how to use them and when. please check other KDocs as well

}

override fun addAll(elements: Collection<T>): Boolean {
val toAdd = elements.filter { it !in set }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if elements has the same element twice it will be added both times


/**
* API marked with this annotation is experimental.
* The shape of the R2DBC DAO API may change in incompatible ways while it stabilizes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* The shape of the R2DBC DAO API may change in incompatible ways while it stabilizes.
* The shape of the R2DBC DAO API may change in incompatible ways while it stabilizes even in patch releases.

// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please log a YT issue for that

}

/**
* Property delegate for [CompositeColumn] — splits [value] into its real-column parts via

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please consider giving example of how to use it instead of explaining the code

* 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<Column<Any?>, Any?>()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should this be public?

@HacktheTime

HacktheTime commented Aug 6, 2026

Copy link
Copy Markdown

I have gotten things working it seems (with coroutine downgraded to 1.10.2). Curious to see how well it works in production (hobby project)

I personally dislike the constant swapping between = and set all the time.

In my opinion a constant suspend invoke() and infix set is the cleaner solution. that would need an adjustment of the getValue Delegate in Entity mainly.

I also went in and added support for references to be more directly accessible in entity via delegates. This way I can define a custom helper method such as user() and define the user column(reference table and id column ) once and afterwards i don't have to bother about it anymore to use reffersOnsuspend etc.
added_reffersonsuspend_delegates.patch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants