Skip to content

feat: EXPOSED-819 Exposed R2DBC DAO - #2831

Open
Oleg Babichev (obabichevjb) wants to merge 21 commits into
mainfrom
obabichev/r2dbc-dao-5
Open

Oleg Babichev (obabichevjb) wants to merge 21 commits into
mainfrom
obabichev/r2dbc-dao-5

Conversation

@obabichevjb

Copy link
Copy Markdown
Collaborator

No description provided.

@obabichevjb

Oleg Babichev (obabichevjb) commented Jun 8, 2026

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.
All public R2DBC DAO API is marked with @ExperimentalR2dbcDaoApi and requires opt-in.

This document highlights behavioral and structural differences between the JDBC DAO (exposed-dao)
and the new R2DBC DAO (exposed-dao-r2dbc). Names are not among them: entity classes and relationship
builders (referencedOn, referrersOn, via, …) keep their JDBC names under the package
org.jetbrains.exposed.v1.dao.r2dbc, so migration is mostly an import change plus the points below.


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

A JDBC many-to-one reference is a mutable property; in R2DBC it is a val returning an accessor:

// JDBC
var director by Director referencedOn Films.director
film.director                     // read
film.director = otherDirector     // write

// R2DBC
val director by Director referencedOn Films.director
film.director()                   // read — suspends, queries unless already cached
film.director.set(otherDirector)  // write — does not suspend, staged like any column write

Why: Kotlin's delegation protocol requires getValue to return the declared property type, and
reading a reference in R2DBC has to suspend, which a property getter cannot do. So the property returns
an accessor with suspend operator fun invoke() for reads and fun set(value) for writes.

Only many-to-one needs that indirection — a one-to-many is already a lazy SizedIterable, and a back
reference can simply be a suspend lambda:

Kind Read Write
referencedOn / optionalReferencedOn film.director() film.director.set(other)
referrersOn / optionalReferrersOn director.films is a SizedIterable<Film> read-only
backReferencedOn / optionalBackReferencedOn film.review() — property type is suspend () -> Review read-only
via film.actors.toList() film.actors = SizedCollection(a1, a2)

Many-to-many stays a var, as in JDBC: assigning a collection rewrites the link table rows.


2. Creating entities: newSuspend { } is the JDBC new { }

JDBC's new { } issues the INSERT before it returns. Its R2DBC counterpart is newSuspend { }, which
schedules the insert, flushes, and returns a hydrated entity — its init block suspends too, so other
DAO operations can be called from inside:

// JDBC
val broker = Broker.new { name = "Alice" }
broker.id.value  // INSERT already happened

// R2DBC
val broker = Broker.newSuspend { name = "Alice" }
broker.id.value  // INSERT already happened

new { } also exists, and does something deliberately different: it schedules the insert without any
database access, which is what makes it callable from a non-suspending context — an entity init block,
a factory function, a builder that assembles an object graph before touching the connection:

// R2DBC
val broker = Broker.new { name = "Alice" }   // nothing sent yet
broker.id.value                              // throws IllegalStateException("Entity must be inserted")
flushCache()                                 // now the INSERT goes out

The id is missing because the row does not exist yet; it is available immediately when it does not come
from the database (an explicit new(id) { } argument, or uuid("id").autoGenerate()). Column reads and
writes work before the flush, and so does using the entity as a reference target — the child stores the
very EntityID the parent's insert fills in, and the cache orders inserts so the parent goes first.

flushCache() is rarely required. A pending insert is issued at the first of:

  • an explicit flushCache() or EntityCache.flush();
  • any other statement in the 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.

Everything pending for one table goes out as a single batch statement, so scheduling several entities and
flushing once is the cheapest way to insert many rows through the DAO.


3. suspend on all I/O methods

Method JDBC R2DBC
EntityClass.new(init) fun new(init): T fun new(init): T — deferred, no I/O
suspend fun newSuspend(init): T
EntityClass.findById(id) fun findById(id): T? suspend fun findById(id): T?
EntityClass[id] operator fun get(id): T suspend operator fun get(id): T
EntityClass.count(op) fun count(op): Long suspend fun count(op): Long
EntityClass.reload(entity) fun reload(entity, flush): T? suspend fun reload(entity, flush): T?
EntityClass.warmUpReferences fun (…, forUpdate, orderBy) suspend fun (…, orderBy) — no forUpdate
Entity.flush(batch) fun flush(batch): Boolean suspend fun flush(batch): Boolean
Entity.delete() fun delete() suspend fun delete()
EntityCache.flush() fun flush() suspend fun flush()
Transaction.flushCache() fun Transaction.flushCache() suspend fun R2dbcTransaction.flushCache()

Hook subscriptions accept suspend lambdas, and EntityLifecycleInterceptor implements
GlobalSuspendStatementInterceptor, so its callbacks suspend as well. Column reads and writes on a
tracked entity stay non-suspending, as do new, detach, find, all, wrapRow and testCache(id).


4. Collections return SizedIterable backed by Flow

R2DBC's SizedIterable<T> extends Flow<T> rather than Iterable<T>, and its count()/empty()
suspend, so collecting takes an explicit toList():

// JDBC
director.films.map { it.title }            // eager Iterable.map

// R2DBC
director.films.toList().map { it.title }   // collect the Flow, then map

Flows are cold, which is the porting hazard worth spelling out: sizedIterable.map { } is a Flow
operator, so every terminal operator applied to its result re-executes the query, where the
identical-looking JDBC expression runs once.

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


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

JDBC auto-attaches an entity when you set a column value, because Column.setValue can query the
database synchronously. R2DBC's setValue cannot suspend, so adoption is explicit — and a write to an
untracked entity throws EntityNotFoundException rather than being silently dropped.

// R2DBC
suspendTransaction {
    Broker.attach(broker)   // verifies the row exists, then tracks this instance
    broker.name = "Bob"     // now safe to modify; flushed by the commit
}

suspend fun attach(entity, force = false) does nothing if the instance is already tracked; stores it
after verifying the row exists; and replaces a different tracked instance of the same row — unless that
one holds unflushed changes, in which case it throws instead of dropping them. force discards them.

fun detach(entity, force = false) is the inverse and needs no database access: it stops tracking the
entity so it is not flushed at commit, reads keep returning committed values while writes throw, and it
detaches from enclosing transactions too, since tracking spans the whole chain. It throws for
uncommitted values unless force is passed, and is safe to call twice.


6. Missing JDBC features (not yet ported)

  • ImmutableEntityClass / ImmutableCachedEntityClass — immutable entities with cross-transaction
    caching, plus forceUpdateEntity and expireCache
  • View and EntityClass.view { }
  • findWithCacheCondition / testCache(cacheCheckCondition) — the Sequence-returning cache scans;
    testCache(id) is available
  • wrapRows(rows, alias) — both alias overloads; the single-row wrapRow(row, alias) forms exist
  • EntityCache.maxEntitiesToStore — the per-entity cache cap is not implemented
  • Entity.writeValues / storeWrittenValues() / lookupInReadValues() — superseded by an internal
    staged-values model
  • forUpdate parameter of warmUpReferences / warmUpOptReferences — still on warmUpLinkedReferences
  • referrersOn(table, cache) / optionalReferrersOn(table, cache) — the composite-FK overloads that
    also take the cache flag; the (column, cache) and plain (table) forms exist

@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

Oleg Babichev (@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

Oleg Babichev (obabichevjb) commented Jul 8, 2026

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>> Same.
var db Database R2dbcDatabase Type changed.
val writeValues REMOVED Pending writes are held by the cache's staged-values model.
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 issues no DELETE when the entity was never flushed — it just drops the scheduled insert.
fun flush(batch: EntityBatchUpdate? = null): Boolean Same.
fun storeWrittenValues() REMOVED Staging is internal in R2DBC.
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 Reference.provideDelegateAccessor.
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 / wrapRow(row, alias) Same.
fun wrapRows(rows) R2DBC has no wrapRows(rows, alias) overloads.
fun reload(entity, flush) Same.
fun removeFromCache(entity) Same.
fun forEntityIds(ids) / fun forIds(ids) Same.
fun testCache(id) Same.
fun testCache(cacheCheckCondition) Missing. The Sequence-returning cache scan.
fun new(init): T / fun new(id, init): T Semantic change. Non-suspend in R2DBC and only schedules the insert; the id stays unset until the flush.
fun newSuspend(init): T / fun newSuspend(id, init): T R2DBC-only, and the real counterpart of JDBC's new { }: schedules, flushes, returns a hydrated entity.
fun attach(entity, force = false) R2DBC-only. Verifies the row exists, then tracks this instance.
fun detach(entity, force = false) R2DBC-only. Non-suspend; stops tracking across the whole transaction chain.
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 R2DBC drops the forUpdate parameter.
fun warmUpLinkedReferences Same parameters.
infix fun referencedOn / optionalReferencedOn / referrersOn / optionalReferrersOn / backReferencedOn / optionalBackReferencedOn Member on EntityClass Member on EntityClass Same location and call-site syntax; return types differ (see relationships section). R2DBC needs no backReferencedOnOpt — its backReferencedOn takes an unconstrained REF — and drops the composite-FK referrersOn(table, cache) overloads.
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<*>, ...> LinkedHashMap in JDBC, ConcurrentHashMap in R2DBC.
fun find(klass, id) Same.
fun store(entity) Same.
fun store(klass, entity) Missing. Only the single-argument form.
fun remove(table, entity) Same.
fun findAll(klass) Returns Collection in JDBC, List in R2DBC.
fun scheduleInsert(klass, entity) Same.
fun scheduleUpdate(klass, entity) Same.
fun getOrPutReferrers(sourceId, key, refs) Same parameter order; R2DBC takes a suspending producer.
fun getReferrers(sourceId, key) Same.
fun clear(flush) Same.
fun clearReferrersCache() Same.
fun flush() / fun flush(tables) Same.
var maxEntitiesToStore Missing. R2DBC has no per-entity cache cap.
companion object: fun invalidateGlobalCaches(created) Missing. Tied to ImmutableCachedEntityClass.

EntityHook / EntityChange

Same public API in both modules — subscribe, unsubscribe, EntityChange(EntityClass, EntityID, EntityChangeType) — except that R2DBC's subscribe/unsubscribe take a suspend (EntityChange) -> Unit, and so does withHook. 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 the returned Reference is Accessor-based.
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 name; returns a public BackReference instead of a bare ReadOnlyProperty.
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)
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() — the property type is suspend () -> Parent
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; separate SizedIterable and Iterable overloads)

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<...> (internal in JDBC) BackReference<...> — public, getValue returns suspend () -> Parent
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. R2DBC's DaoEntityID also does not override invokeOnNoValue, so an unflushed id throws instead of auto-flushing.

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

R2DBC class Purpose
Accessor / OptionalAccessor Runtime many-to-one accessor (suspend invoke() to read, set(value) to write).
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
@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

Oleg Babichev (@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)
}

@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?

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.

I agree, especially because I got confused initially by the choice of the word -showcase.
I would vote for splitting this into 2 separate and dedicated samples -> exposed-jdbc-dao-sample + exposed-r2dbc-dao-sample.

Could the addition of these samples also please be removed into its own separate PR? Would make reviewing this again in the future a little bit easier 🙏

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I will move it to separate PR

Comment thread exposed-dao-r2dbc/src/main/kotlin/org/jetbrains/exposed/v1/dao/r2dbc/Entity.kt Outdated
* 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?

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.

It (and related readValues) have always been public for JDBC DAO

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This field doesn't exist anymore, but visibility of cache data could be revisited anyway.

@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

Comment thread buildSrc/src/main/kotlin/org/jetbrains/exposed/gradle/Publishing.kt Outdated
@@ -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.

I agree, especially because I got confused initially by the choice of the word -showcase.
I would vote for splitting this into 2 separate and dedicated samples -> exposed-jdbc-dao-sample + exposed-r2dbc-dao-sample.

Could the addition of these samples also please be removed into its own separate PR? Would make reviewing this again in the future a little bit easier 🙏

Comment thread samples/exposed-dao-showcase/gradle/libs.versions.toml Outdated
Comment thread samples/exposed-dao-showcase/README.md Outdated
Comment thread samples/exposed-dao-showcase/README.md Outdated
Comment thread documentation-website/Writerside/topics/Migration-Guide-DAO-JDBC-to-R2DBC.md Outdated
Comment thread documentation-website/Writerside/topics/Migration-Guide-DAO-JDBC-to-R2DBC.md Outdated
Comment thread exposed-dao-r2dbc-tests/build.gradle.kts
* 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.

It (and related readValues) have always been public for JDBC DAO

* 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
… entity which is already modified in the current cache.
@obabichevjb
Oleg Babichev (obabichevjb) force-pushed the obabichev/r2dbc-dao-5 branch 2 times, most recently from c1ecb8a to 573f980 Compare September 3, 2026 11:25
@obabichevjb

Copy link
Copy Markdown
Collaborator Author

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?

I would give that question more attention, and check what is the primary use case of reattaching entities between transactions. Does it allow to do something better what could not be done just by reading entities from database? Is the reattached entities more or less just a container for entity class and entity id?

@obabichevjb
Oleg Babichev (obabichevjb) force-pushed the obabichev/r2dbc-dao-5 branch 5 times, most recently from d55880f to e45da8b Compare September 4, 2026 11:59
@HacktheTime

HacktheTime commented Sep 4, 2026

Copy link
Copy Markdown

I haven't synchronised the code in a month or so but had some issues recently I am gonna look into in the next few days if there is a occasion to do so.

Wanted to share some of these Issues but again note that this may be only due to outdated code!
1)
Entity is not initialized yet. Call flush() or reload the entity from the database.

I believe this came after the entity existed for quite some time already.

Details

Type: IllegalStateException
Message: Entity is not initialized yet. Call flush() or reload the entity from the database.
org.jetbrains.exposed.v1.dao.r2dbc.Entity.getValue(Entity.kt:55)
org.jetbrains.exposed.v1.dao.r2dbc.Entity.getValue(Entity.kt:142)
….objects.BBUser(BBUser.kt:103)
….modserver.ClientHandler(ClientHandler.kt:114)
….modserver.BingoNetServer(BingoNetServer.kt:168)
….modserver.ClientHandler$disconnect$2(ClientHandler.kt:391)

Suppressed (submission trace):
….managers.ExecutionService(ExecutionService.kt:58)
….managers.ExecutionService(ExecutionService.kt:179)
….modserver.BingoNetServer$start$4(BingoNetServer.kt:139)
….modserver.BingoNetServer$start$4(BingoNetServer.kt:-1)
….modserver.BingoNetServer$start$4(BingoNetServer.kt:-1)
….managers.ExecutionService$launchManaged$job$1$2(ExecutionService.kt:202)
….managers.ExecutionService$launchManaged$job$1$2(ExecutionService.kt:-1)
….managers.ExecutionService$launchManaged$job$1$2(ExecutionService.kt:-1)
….managers.ExecutionService$executeWithinTransaction$2(ExecutionService.kt:290)
….managers.ExecutionService$executeWithinTransaction$2(ExecutionService.kt:-1)
….managers.ExecutionService$executeWithinTransaction$2(ExecutionService.kt:-1)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt$inTopLevelSuspendTransaction$2.invokeSuspend(Transactions.kt:194)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt$inTopLevelSuspendTransaction$2.invoke(Transactions.kt)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt$inTopLevelSuspendTransaction$2.invoke(Transactions.kt)
kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(Undispatched.kt:66)
kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:165)
kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
org.jetbrains.exposed.v1.r2dbc.R2dbcTransactionKt.withTransactionContext(R2dbcTransaction.kt:376)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt.inTopLevelSuspendTransaction(Transactions.kt:190)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt.suspendTransaction(Transactions.kt:136)
org.jetbrains.exposed.v1.r2dbc.transactions.TransactionsKt.suspendTransaction$default(Transactions.kt:110)
….managers.ExecutionService(ExecutionService.kt:289)
….managers.ExecutionService(ExecutionService.kt:304)
….managers.ExecutionService(ExecutionService.kt:23)
….managers.ExecutionService$launchManaged$job$1(ExecutionService.kt:202)
kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
java.base/java.lang.Thread.run(Thread.java:1474)

Message: The connection is closed. Unable to send anything
afair this happened after the connection was open for a long time due to the bot not being restarted etc.

Details Type: ExposedR2dbcException Message: The connection is closed. Unable to send anything SQL: (normal statement here) org.jetbrains.exposed.v1.dao.r2dbc.EntityClass.findById(EntityClass.kt) org.jetbrains.exposed.v1.dao.r2dbc.EntityClass.findById(EntityClass.kt:228) ….abuse.IPAbuseHandler$getIpReport$2$reportInDb$1(IPAbuseHandler.kt:108) ┌ Repeating 2 Times [Block 1] ├ ….abuse.IPAbuseHandler$getIpReport$2$reportInDb$1(IPAbuseHandler.kt:-1) └End of Repeat [Block 1] kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(Undispatched.kt:66) kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43) kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:165) kotlinx.coroutines.BuildersKt.withContext(Unknown Source) ….abuse.IPAbuseHandler$getIpReport$2(IPAbuseHandler.kt:108) ┌ Repeating 2 Times [Block 2] ├ ….abuse.IPAbuseHandler$getIpReport$2(IPAbuseHandler.kt:-1) └End of Repeat [Block 2] ….abuse.IPAbuseHandler(IPAbuseHandler.kt:97) ….abuse.IPAbuseHandler(IPAbuseHandler.kt:107) ….abuse.IPAbuseHandler(IPAbuseHandler.kt:374) ….modserver.BingoNetServer(BingoNetServer.kt:102) ….modserver.BingoNetServer$start$1(BingoNetServer.kt:-1) Caused By: Type: R2dbcNonTransientResourceException The connection is closed. Unable to send anything org.jetbrains.exposed.v1.dao.r2dbc.EntityClass.findById(EntityClass.kt) org.jetbrains.exposed.v1.dao.r2dbc.EntityClass.findById(EntityClass.kt:228) ….abuse.IPAbuseHandler$getIpReport$2$reportInDb$1(IPAbuseHandler.kt:108) ┌ Repeating 2 Times [Block 1] ├ ….abuse.IPAbuseHandler$getIpReport$2$reportInDb$1(IPAbuseHandler.kt:-1) └End of Repeat [Block 1] kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(Undispatched.kt:66) kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43) kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:165) kotlinx.coroutines.BuildersKt.withContext(Unknown Source) ….abuse.IPAbuseHandler$getIpReport$2(IPAbuseHandler.kt:108) ┌ Repeating 2 Times [Block 2] ├ ….abuse.IPAbuseHandler$getIpReport$2(IPAbuseHandler.kt:-1) └End of Repeat [Block 2] ….abuse.IPAbuseHandler(IPAbuseHandler.kt:97) ….abuse.IPAbuseHandler(IPAbuseHandler.kt:107) ….abuse.IPAbuseHandler(IPAbuseHandler.kt:374) ….modserver.BingoNetServer(BingoNetServer.kt:102) ….modserver.BingoNetServer$start$1(BingoNetServer.kt:-1)

PS: I updated the code just now. will inform of things if i see anything.

@HacktheTime

HacktheTime commented Sep 4, 2026

Copy link
Copy Markdown

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?

I would give that question more attention, and check what is the primary use case of reattaching entities between transactions. Does it allow to do something better what could not be done just by reading entities from database? Is the reattached entities more or less just a container for entity class and entity id?

Also I thought about your message here again.

One instance where i have long existing entities right now is for connections from clients. If i permanently need to re request them from the database that seems at minimum annoying if not inefficient (idk if a change listener that updates values live is possible).

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.

4 participants