diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt index 2aef54723d..ea2c62286d 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt @@ -8,14 +8,22 @@ import org.jetbrains.exposed.sql.transactions.TransactionManager import kotlin.properties.Delegates import kotlin.reflect.KProperty +/** + * Class responsible for enabling [Entity] field transformations, which may be useful when advanced database + * type conversions are necessary for entity mappings. + */ open class ColumnWithTransform( + /** The original column type used in the transformation. */ val column: Column, + /** The function used to convert a transformed value to a value that can be stored in the original column type. */ val toColumn: (TReal) -> TColumn, toReal: (TColumn) -> TReal, + /** Whether the original and transformed values should be cached to avoid multiple conversion calls. */ protected val cacheResult: Boolean = false ) { private var cache: Pair? = null + /** The function used to transform a value stored in the original column type. */ val toReal: (TColumn) -> TReal = { columnValue -> if (cacheResult) { val localCache = cache @@ -30,18 +38,27 @@ open class ColumnWithTransform( } } +/** + * Class representing a mapping to values stored in a table record in a database. + * + * @param id The unique stored identity value for the mapped record. + */ open class Entity>(val id: EntityID) { + /** The associated [EntityClass] that manages this [Entity] instance. */ var klass: EntityClass> by Delegates.notNull() internal set + /** The [Database] associated with the record mapped to this [Entity] instance. */ var db: Database by Delegates.notNull() internal set + /** The initial column-value mapping for this [Entity] instance before being flushed and inserted into the database. */ val writeValues = LinkedHashMap, Any?>() @Suppress("VariableNaming") var _readValues: ResultRow? = null + /** The final column-value mapping for this [Entity] instance after being flushed and retrieved from the database. */ val readValues: ResultRow get() = _readValues ?: run { val table = klass.table @@ -58,11 +75,12 @@ open class Entity>(val id: EntityID) { } /** - * Updates entity fields from database. - * Override function to refresh some additional state if any. + * Updates the fields of this [Entity] instance with values retrieved from the database. + * Override this function to refresh some additional state, if any. * - * @param flush whether pending entity changes should be flushed previously - * @throws EntityNotFoundException if entity no longer exists in database + * @param flush Whether pending entity changes should be flushed prior to updating. + * @throws EntityNotFoundException If the entity no longer exists in the database. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.testNewWithIdAndRefresh */ open fun refresh(flush: Boolean = false) { val transaction = TransactionManager.current() @@ -182,6 +200,12 @@ open class Entity>(val id: EntityID) { return this.restoreValueFromParts(values) } + /** + * Checks if this column has been assigned a value retrieved from the database, then calls the [found] block + * with this value as its argument, and returns its result. + * + * If a column-value mapping has not been retrieved, the result of calling the [notFound] block is returned instead. + */ fun Column.lookupInReadValues(found: (T?) -> R?, notFound: () -> R?): R? = if (_readValues?.hasValue(this) == true) { found(readValues[this]) @@ -189,6 +213,12 @@ open class Entity>(val id: EntityID) { notFound() } + /** + * Returns the value assigned to this column mapping. + * + * Depending on the state of this [Entity] instance, the value returned may be the initial property assignment, + * this column's default value, or the value retrieved from the database. + */ @Suppress("UNCHECKED_CAST", "USELESS_CAST") fun Column.lookup(): T = when { writeValues.containsKey(this as Column) -> writeValues[this as Column] as T @@ -231,18 +261,43 @@ open class Entity>(val id: EntityID) { column.setValue(o, desc, toColumn(value)) } - infix fun , Target : Entity> EntityClass.via(table: Table): InnerTableLink, TID, Target> = + /** + * Registers a reference as a field of the child entity class, which returns a parent object of this [EntityClass], + * for use in many-to-many relations. + * + * The reference should have been defined by the creation of a column using `reference()` on an intermediate table. + * + * @param table The intermediate table containing reference columns to both child and parent objects. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityHookTestData.User + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityHookTestData.City + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityHookTestData.UsersToCities + */ + infix fun , Target : Entity> EntityClass.via( + table: Table + ): InnerTableLink, TID, Target> = InnerTableLink(table, this@Entity.id.table, this@via) + /** + * Registers a reference as a field of the child entity class, which returns a parent object of this [EntityClass], + * for use in many-to-many relations. + * + * The reference should have been defined by the creation of a column using `reference()` on an intermediate table. + * + * @param sourceColumn The intermediate table's reference column for the child entity class. + * @param targetColumn The intermediate table's reference column for the parent entity class. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.NodesTable + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.Node + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.NodeToNodes + */ fun , Target : Entity> EntityClass.via( sourceColumn: Column>, targetColumn: Column> ) = InnerTableLink(sourceColumn.table, this@Entity.id.table, this@via, sourceColumn, targetColumn) /** - * Delete this entity. + * Deletes this [Entity] instance, both from the cache and from the database. * - * This will remove the entity from the database as well as the cache. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.testErrorOnSetToDeletedEntity */ open fun delete() { val table = klass.table @@ -255,6 +310,14 @@ open class Entity>(val id: EntityID) { klass.removeFromCache(this) } + /** + * Sends all cached inserts and updates for this [Entity] instance to the database. + * + * @param batch The [EntityBatchUpdate] instance that should be used to perform a batch update operation + * for multiple entities. If left `null`, a single update operation will be executed for this entity only. + * @return `false` if no cached inserts or updates were sent to the database; `true`, otherwise. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityHookTest.testCallingFlushNotifiesEntityHookSubscribers + */ open fun flush(batch: EntityBatchUpdate? = null): Boolean { if (isNewEntity()) { TransactionManager.current().entityCache.flushInserts(this.klass.table) @@ -290,6 +353,7 @@ open class Entity>(val id: EntityID) { return false } + /** Transfers initial column-value mappings from [writeValues] to [readValues] and clears the former once complete. */ fun storeWrittenValues() { // move write values to read values if (_readValues != null) { diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt index b9afaac05a..327d8cacf3 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt @@ -461,14 +461,29 @@ abstract class EntityClass, out T : Entity>( ) = registerRefRule(column) { OptionalReferrers, TargetID, Target, REF>(column, this, cache) } + /** + * Returns a [ColumnWithTransform] delegate that transforms this stored [TColumn] value on every read. + * + * @param toColumn A pure function that converts a transformed value to a value that can be stored + * in this original column type. + * @param toReal A pure function that transforms a value stored in this original column type. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.TransformationsTable + * @sample org.jetbrains.exposed.sql.tests.shared.entities.TransformationEntity + */ fun Column.transform( toColumn: (TReal) -> TColumn, toReal: (TColumn) -> TReal ): ColumnWithTransform = ColumnWithTransform(this, toColumn, toReal, false) /** - * Function will return [ColumnWithTransform] delegate that will cache value on read for the same [TColumn] value. - * @param toReal should be pure function + * Returns a [ColumnWithTransform] delegate that will cache the transformed value on first read of + * this same stored [TColumn] value. + * + * @param toColumn A pure function that converts a transformed value to a value that can be stored + * in this original column type. + * @param toReal A pure function that transforms a value stored in this original column type. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.TransformationsTable + * @sample org.jetbrains.exposed.sql.tests.shared.entities.TransformationEntity */ fun Column.memoizedTransform( toColumn: (TReal) -> TColumn, diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityHook.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityHook.kt index 26f8b16f1b..df0334a0a8 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityHook.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityHook.kt @@ -8,21 +8,42 @@ import java.util.Deque import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.ConcurrentLinkedQueue +/** Represents the possible states of an [Entity] throughout its lifecycle. */ enum class EntityChangeType { + /** The entity has been inserted in the database. */ Created, + + /** The entity has been updated in the database. */ Updated, + + /** The entity has been removed from the database. */ Removed } +/** Stores details about a state-change event for an [Entity] instance. */ data class EntityChange( + /** The [EntityClass] of the changed entity instance. */ val entityClass: EntityClass<*, Entity<*>>, + /** The unique [EntityID] associated with the entity instance. */ val entityId: EntityID<*>, + /** The exact changed state of the event. */ val changeType: EntityChangeType, + /** The unique id for the [Transaction] in which the event took place. */ val transactionId: String ) -fun , T : Entity> EntityChange.toEntity(): T? = (entityClass as EntityClass).findById(entityId as EntityID) +/** + * Returns the actual [Entity] instance associated with [this][EntityChange] event, + * or `null` if the entity is not found. + */ +fun , T : Entity> EntityChange.toEntity(): T? = + (entityClass as EntityClass).findById(entityId as EntityID) +/** + * Returns the actual [Entity] instance associated with [this][EntityChange] event, + * or `null` if either its [EntityClass] type is neither equivalent to nor a subclass of [klass], + * or if the entity is not found. + */ fun , T : Entity> EntityChange.toEntity(klass: EntityClass): T? { if (!entityClass.isAssignableTo(klass)) return null return toEntity() @@ -32,18 +53,33 @@ private val Transaction.unprocessedEvents: Deque by transactionSco private val Transaction.entityEvents: Deque by transactionScope { ConcurrentLinkedDeque() } private val entitySubscribers = ConcurrentLinkedQueue<(EntityChange) -> Unit>() +/** + * Class responsible for providing functions that expose [EntityChange] state logic and entity lifecycle features + * for alerting triggers or customizing additional functionality. + */ object EntityHook { + /** + * Registers a specific state-change [action] for alerts and returns the [action]. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityHookTest.testCallingFlushNotifiesEntityHookSubscribers + */ fun subscribe(action: (EntityChange) -> Unit): (EntityChange) -> Unit { entitySubscribers.add(action) return action } + /** Unregisters a specific state-change [action] from alerts. */ fun unsubscribe(action: (EntityChange) -> Unit) { entitySubscribers.remove(action) } } -fun Transaction.registerChange(entityClass: EntityClass<*, Entity<*>>, entityId: EntityID<*>, changeType: EntityChangeType) { +/** Creates a new [EntityChange] with [this][Transaction] id and registers it as an entity event. */ +fun Transaction.registerChange( + entityClass: EntityClass<*, Entity<*>>, + entityId: EntityID<*>, + changeType: EntityChangeType +) { EntityChange(entityClass, entityId, changeType, id).let { if (unprocessedEvents.peekLast() != it) { unprocessedEvents.addLast(it) @@ -53,6 +89,11 @@ fun Transaction.registerChange(entityClass: EntityClass<*, Entity<*>>, entityId: } private var isProcessingEventsLaunched by transactionScope { false } + +/** + * Triggers alerts for all unprocessed entity events using any state-change actions previously registered + * via [EntityHook.subscribe]. + */ fun Transaction.alertSubscribers() { if (isProcessingEventsLaunched) return while (true) { @@ -66,8 +107,14 @@ fun Transaction.alertSubscribers() { } } +/** Returns a list of all [EntityChange] events that have been registered in this [Transaction]. */ fun Transaction.registeredChanges() = entityEvents.toList() +/** + * Calls the specified function [body] with the given state-change [action], registers the action, and returns its result. + * + * The [action] will be unregistered at the end of the call to the [body] block. + */ fun withHook(action: (EntityChange) -> Unit, body: () -> T): T { EntityHook.subscribe(action) try { diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityLifecycleInterceptor.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityLifecycleInterceptor.kt index 00e8593ee4..f9b73013bb 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityLifecycleInterceptor.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityLifecycleInterceptor.kt @@ -21,6 +21,10 @@ internal fun executeAsPartOfEntityLifecycle(body: () -> T): T { } } +/** + * Represents a [StatementInterceptor] specifically responsible for the statement lifecycle of [Entity] instances, + * which is loaded whenever a [Transaction] instance is initialized. + */ class EntityLifecycleInterceptor : GlobalStatementInterceptor { override fun keepUserDataInTransactionStoreOnCommit(userData: Map, Any?>): Map, Any?> { diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/exceptions/EntityNotFoundException.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/exceptions/EntityNotFoundException.kt index f820483085..627ab16ec0 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/exceptions/EntityNotFoundException.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/exceptions/EntityNotFoundException.kt @@ -3,5 +3,9 @@ package org.jetbrains.exposed.dao.exceptions import org.jetbrains.exposed.dao.EntityClass import org.jetbrains.exposed.dao.id.EntityID +/** + * An exception that provides information about an [entity] that could not be accessed + * either within the scope of the current entity cache or as a result of a database search error. + */ class EntityNotFoundException(val id: EntityID<*>, val entity: EntityClass<*, *>) : Exception("Entity ${entity.klass.simpleName}, id=$id not found in the database") diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityHookTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityHookTest.kt index 0e951db9db..90e1606765 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityHookTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityHookTest.kt @@ -306,7 +306,7 @@ class EntityHookTest : DatabaseTestsBase() { } @Test - fun `calling flush notifies EntityHook subscribers`() { + fun testCallingFlushNotifiesEntityHookSubscribers() { withTables(EntityHookTestData.User.table) { var hookCalls = 0 val user = EntityHookTestData.User.new {