Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: Add missing KDocs for exposed-dao Entity API #2012

Merged
merged 3 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TColumn, TReal>(
/** The original column type used in the transformation. */
val column: Column<TColumn>,
/** 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<TColumn, TReal>? = null

/** The function used to transform a value stored in the original column type. */
val toReal: (TColumn) -> TReal = { columnValue ->
if (cacheResult) {
val localCache = cache
Expand All @@ -30,18 +38,27 @@ open class ColumnWithTransform<TColumn, TReal>(
}
}

/**
* 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<ID : Comparable<ID>>(val id: EntityID<ID>) {
/** The associated [EntityClass] that manages this [Entity] instance. */
var klass: EntityClass<ID, Entity<ID>> 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<Column<Any?>, 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
Expand All @@ -58,11 +75,12 @@ open class Entity<ID : Comparable<ID>>(val id: EntityID<ID>) {
}

/**
* 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()
Expand Down Expand Up @@ -182,13 +200,25 @@ open class Entity<ID : Comparable<ID>>(val id: EntityID<ID>) {
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 <T, R : Any> Column<T>.lookupInReadValues(found: (T?) -> R?, notFound: () -> R?): R? =
if (_readValues?.hasValue(this) == true) {
found(readValues[this])
} else {
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 <T> Column<T>.lookup(): T = when {
writeValues.containsKey(this as Column<out Any?>) -> writeValues[this as Column<out Any?>] as T
Expand Down Expand Up @@ -231,18 +261,43 @@ open class Entity<ID : Comparable<ID>>(val id: EntityID<ID>) {
column.setValue(o, desc, toColumn(value))
}

infix fun <TID : Comparable<TID>, Target : Entity<TID>> EntityClass<TID, Target>.via(table: Table): InnerTableLink<ID, Entity<ID>, 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 <TID : Comparable<TID>, Target : Entity<TID>> EntityClass<TID, Target>.via(
table: Table
): InnerTableLink<ID, Entity<ID>, TID, Target> =
InnerTableLink(table, [email protected], 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 <TID : Comparable<TID>, Target : Entity<TID>> EntityClass<TID, Target>.via(
sourceColumn: Column<EntityID<ID>>,
targetColumn: Column<EntityID<TID>>
) = InnerTableLink(sourceColumn.table, [email protected], 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
Expand All @@ -255,6 +310,14 @@ open class Entity<ID : Comparable<ID>>(val id: EntityID<ID>) {
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)
Expand Down Expand Up @@ -290,6 +353,7 @@ open class Entity<ID : Comparable<ID>>(val id: EntityID<ID>) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,14 +461,29 @@ abstract class EntityClass<ID : Comparable<ID>, out T : Entity<ID>>(
) =
registerRefRule(column) { OptionalReferrers<ID, Entity<ID>, 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 <TColumn : Any?, TReal : Any?> Column<TColumn>.transform(
toColumn: (TReal) -> TColumn,
toReal: (TColumn) -> TReal
): ColumnWithTransform<TColumn, TReal> = 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 <TColumn : Any?, TReal : Any?> Column<TColumn>.memoizedTransform(
toColumn: (TReal) -> TColumn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ID : Comparable<ID>, T : Entity<ID>> EntityChange.toEntity(): T? = (entityClass as EntityClass<ID, T>).findById(entityId as EntityID<ID>)
/**
* Returns the actual [Entity] instance associated with [this][EntityChange] event,
* or `null` if the entity is not found.
*/
fun <ID : Comparable<ID>, T : Entity<ID>> EntityChange.toEntity(): T? =
(entityClass as EntityClass<ID, T>).findById(entityId as EntityID<ID>)

/**
* 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 <ID : Comparable<ID>, T : Entity<ID>> EntityChange.toEntity(klass: EntityClass<ID, T>): T? {
if (!entityClass.isAssignableTo(klass)) return null
return toEntity<ID, T>()
Expand All @@ -32,18 +53,33 @@ private val Transaction.unprocessedEvents: Deque<EntityChange> by transactionSco
private val Transaction.entityEvents: Deque<EntityChange> 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)
Expand All @@ -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) {
Expand All @@ -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 <T> withHook(action: (EntityChange) -> Unit, body: () -> T): T {
EntityHook.subscribe(action)
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ internal fun <T> 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<Key<*>, Any?>): Map<Key<*>, Any?> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading