From c27defa1def03e4e5bb363fd9f0eb37a7a5f7d80 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:48:37 -0400 Subject: [PATCH 1/5] feat!: EXPOSED-320 Many-to-many relation with extra columns An intermediate table is defined to link a many-to-many relation between 2 IdTables, with references defined using via(). If this intermediate table is defined with additional columns, these are not accessible through the linked entities. This PR refactors the InnerTableLink logic to include additional columns in generated SQL, which can be accessed as a regular field on one of the referencing entities. It also allows the possibility to access the additional data as a new entity type, which wraps the main child entity along with the additional fields. This is accomplished with the introduction of InnerTableLinkEntity. --- .../Writerside/topics/Breaking-Changes.md | 16 +++ .../Writerside/topics/Deep-Dive-into-DAO.md | 118 ++++++++++++++++-- exposed-dao/api/exposed-dao.api | 19 ++- .../org/jetbrains/exposed/dao/Entity.kt | 24 +++- .../org/jetbrains/exposed/dao/EntityCache.kt | 8 +- .../jetbrains/exposed/dao/InnerTableLink.kt | 71 ++++++++++- .../sql/tests/shared/entities/ViaTest.kt | 115 ++++++++++------- 7 files changed, 311 insertions(+), 60 deletions(-) diff --git a/documentation-website/Writerside/topics/Breaking-Changes.md b/documentation-website/Writerside/topics/Breaking-Changes.md index c762298453..2d7cd3d024 100644 --- a/documentation-website/Writerside/topics/Breaking-Changes.md +++ b/documentation-website/Writerside/topics/Breaking-Changes.md @@ -27,6 +27,22 @@ * The transformation of a nullable column (`Column.transform()`) requires handling null values. This enables conversions from `null` to a non-nullable value, and vice versa. * In H2 the definition of json column with default value changed from `myColumn JSON DEFAULT '{"key": "value"}'` to `myColumn JSON DEFAULT JSON '{"key": "value"}'` +* Additional columns from intermediate tables (defined for use with DAO `via()` for many-to-many relations) are no longer ignored on batch insert of references. + These columns are now included in the generated SQL and a value will be required when setting references, unless column defaults are defined. + +To continue to ignore these columns, use the non-infix version of `via()` and provide an empty list to `additionalColumns` (or a list of specific columns to include): +```kotlin +// given an intermediate table StarWarsFilmActors with extra columns that should be ignored +class StarWarsFilm(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(StarWarsFilms) + // ... + var actors by Actor.via( + sourceColumn = StarWarsFilmActors.starWarsFilm, + targetColumn = StarWarsFilmActors.actor, + additionalColumns = emptyList() + ) +} +``` ## 0.54.0 diff --git a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md index a46e843a9c..b5db5f4238 100644 --- a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md +++ b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md @@ -321,13 +321,13 @@ class User(id: EntityID) : IntEntity(id) { ### Many-to-many reference In some cases, a many-to-many reference may be required. -Let's assume you want to add a reference to the following Actors table to the StarWarsFilm class: +Let's assume you want to add a reference to the following `Actors` table to the `StarWarsFilm` class: ```kotlin -object Actors: IntIdTable() { +object Actors : IntIdTable() { val firstname = varchar("firstname", 50) val lastname = varchar("lastname", 50) } -class Actor(id: EntityID): IntEntity(id) { +class Actor(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Actors) var firstname by Actors.firstname var lastname by Actors.lastname @@ -345,21 +345,119 @@ Add a reference to `StarWarsFilm`: ```kotlin class StarWarsFilm(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(StarWarsFilms) - ... + // ... var actors by Actor via StarWarsFilmActors - ... + // ... } ``` -Note: You can set up IDs manually inside a transaction like this: +Note: You can set up IDs manually inside a transaction and set all referenced actors like this: ```kotlin transaction { - // only works with UUIDTable and UUIDEntity - StarWarsFilm.new (UUID.randomUUID()){ - ... - actors = SizedCollection(listOf(actor)) + StarWarsFilm.new(421) { + // ... + actors = SizedCollection(actor1, actor2) + } +} +``` +Now you can access all actors (and their fields) for a `StarWarsFilm` object, `film`, in the same way you would get any other field: +```kotlin +film.actors.first() // returns an Actor object +film.actors.map { it.lastname } // returns a List +``` +If the intermediate table is defined with more than just the two reference columns, these additional columns can be accessed in two ways, both detailed below. + +Given a `StarWarsFilmActors` table with the extra column `roleName`: +```kotlin +object StarWarsFilmActors : Table() { + val starWarsFilm = reference("starWarsFilm", StarWarsFilms) + val actor = reference("actor", Actors) + val roleName = varchar("role_name", 64) + override val primaryKey = PrimaryKey(starWarsFilm, actor) +} +``` +**The first approach** assumes that the value stored in this extra column will be accessed from the `Actor` class: +```kotlin +class Actor(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Actors) + var firstname by Actors.firstname + var lastname by Actors.lastname + var roleName by StarWarsFilmActors.roleName +} +``` +This extra value can then be set, for example, when a new `Actor` is created or when it is provided to the parent entity's field, +and accessed like any other field: +```kotlin +val actor1 = Actor.new { + firstname = "Harrison" + lastname = "Ford" + roleName = "Han Solo" +} +// or +film.actors = SizedCollection(actor1, actor2.apply { roleName = "Ben Solo" }) + +StarWarsFilm.all().first.actors.map { it.roleName } +``` +**The second approach** assumes that the `Actor` class should not be given an extra field and that the extra value stored should +be accessed through an object that holds both the child entity and the additional data. + +To both allow this and still take advantage of the underlying DAO cache, a new entity class has to be defined using `InnerTableLinkEntity`, +which details how to get and set the additional column values from the intermediate table through two overrides: +```kotlin +class ActorWithRole( + val actor: Actor, + val roleName: String +) : InnerTableLinkEntity(actor) { + override fun getInnerTableLinkValue(column: Column<*>): Any = when (column) { + StarWarsFilmActors.roleName -> roleName + else -> error("Column does not exist in intermediate table") + } + + companion object : InnerTableLinkEntityClass(Actors) { + override fun createInstance(entityId: EntityID, row: ResultRow?) = row?.let { + ActorWithRole(Actor.wrapRow(it), it[StarWarsFilmActors.roleName]) + } ?: ActorWithRole(Actor(entityId), "") } } ``` +The original entity class reference now looks like this: +```kotlin +class StarWarsFilm(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(StarWarsFilms) + // ... + var actors by ActorWithRole via StarWarsFilmActors +} +class Actor(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Actors) + var firstname by Actors.firstname + var lastname by Actors.lastname +} +``` +This extra value can then be set by providing a new `ActorWithRole` instance to the parent entity field, and accessed as before: +```kotlin +film.actors = SizedCollection( + ActorWithRole(actor1, "Han Solo"), + ActorWithRole(actor2, "Ben Solo") +) + +StarWarsFilm.all().first.actors.map { it.roleName } +``` + +If only some additional columns in the intermediate table should be used during batch insert, these can be specified by using +via() with an argument provided to additionalColumns. + +class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) { + companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms) + // ... + var actors by ActorWithRole.via( + sourceColumn = StarWarsFilmActors.starWarsFilm, + targetColumn = StarWarsFilmActors.actor, + additionalColumns = listOf(StarWarsFilmActors.roleName) + ) +} + +Setting this parameter to an emptyList() means all additional columns will be ignored. + + ### Parent-Child reference Parent-child reference is very similar to many-to-many version, but an intermediate table contains both references to the same table. Let's assume you want to build a hierarchical entity which could have parents and children. Our tables and an entity mapping will look like diff --git a/exposed-dao/api/exposed-dao.api b/exposed-dao/api/exposed-dao.api index 65bc0bd4a0..8748958c32 100644 --- a/exposed-dao/api/exposed-dao.api +++ b/exposed-dao/api/exposed-dao.api @@ -23,6 +23,7 @@ public class org/jetbrains/exposed/dao/Entity { public static synthetic fun flush$default (Lorg/jetbrains/exposed/dao/Entity;Lorg/jetbrains/exposed/dao/EntityBatchUpdate;ILjava/lang/Object;)Z public final fun getDb ()Lorg/jetbrains/exposed/sql/Database; public final fun getId ()Lorg/jetbrains/exposed/dao/id/EntityID; + public fun getInnerTableLinkValue (Lorg/jetbrains/exposed/sql/Column;)Ljava/lang/Object; public final fun getKlass ()Lorg/jetbrains/exposed/dao/EntityClass; public final fun getReadValues ()Lorg/jetbrains/exposed/sql/ResultRow; public final fun getValue (Lorg/jetbrains/exposed/dao/EntityFieldWithTransform;Lorg/jetbrains/exposed/dao/Entity;Lkotlin/reflect/KProperty;)Ljava/lang/Object; @@ -43,8 +44,9 @@ public class org/jetbrains/exposed/dao/Entity { public final fun setValue (Lorg/jetbrains/exposed/sql/CompositeColumn;Lorg/jetbrains/exposed/dao/Entity;Lkotlin/reflect/KProperty;Ljava/lang/Object;)V public final fun set_readValues (Lorg/jetbrains/exposed/sql/ResultRow;)V public final fun storeWrittenValues ()V - public final fun via (Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;)Lorg/jetbrains/exposed/dao/InnerTableLink; + public final fun via (Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Ljava/util/List;)Lorg/jetbrains/exposed/dao/InnerTableLink; public final fun via (Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Table;)Lorg/jetbrains/exposed/dao/InnerTableLink; + public static synthetic fun via$default (Lorg/jetbrains/exposed/dao/Entity;Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Ljava/util/List;ILjava/lang/Object;)Lorg/jetbrains/exposed/dao/InnerTableLink; } public final class org/jetbrains/exposed/dao/EntityBatchUpdate { @@ -236,8 +238,8 @@ public abstract class org/jetbrains/exposed/dao/ImmutableEntityClass : org/jetbr } public final class org/jetbrains/exposed/dao/InnerTableLink : kotlin/properties/ReadWriteProperty { - public fun (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/dao/id/IdTable;Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;)V - public synthetic fun (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/dao/id/IdTable;Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/dao/id/IdTable;Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Ljava/util/List;)V + public synthetic fun (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/dao/id/IdTable;Lorg/jetbrains/exposed/dao/EntityClass;Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Column;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getSourceColumn ()Lorg/jetbrains/exposed/sql/Column; public final fun getTable ()Lorg/jetbrains/exposed/sql/Table; public final fun getTarget ()Lorg/jetbrains/exposed/dao/EntityClass; @@ -251,6 +253,17 @@ public final class org/jetbrains/exposed/dao/InnerTableLink : kotlin/properties/ public fun setValue (Lorg/jetbrains/exposed/dao/Entity;Lkotlin/reflect/KProperty;Lorg/jetbrains/exposed/sql/SizedIterable;)V } +public abstract class org/jetbrains/exposed/dao/InnerTableLinkEntity : org/jetbrains/exposed/dao/Entity { + public fun (Lorg/jetbrains/exposed/dao/Entity;)V + public abstract fun getInnerTableLinkValue (Lorg/jetbrains/exposed/sql/Column;)Ljava/lang/Object; + public final fun getWrapped ()Lorg/jetbrains/exposed/dao/Entity; +} + +public abstract class org/jetbrains/exposed/dao/InnerTableLinkEntityClass : org/jetbrains/exposed/dao/EntityClass { + public fun (Lorg/jetbrains/exposed/dao/id/IdTable;)V + protected abstract fun createInstance (Lorg/jetbrains/exposed/dao/id/EntityID;Lorg/jetbrains/exposed/sql/ResultRow;)Lorg/jetbrains/exposed/dao/InnerTableLinkEntity; +} + public abstract class org/jetbrains/exposed/dao/IntEntity : org/jetbrains/exposed/dao/Entity { public fun (Lorg/jetbrains/exposed/dao/id/EntityID;)V } 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 023a03a017..d595f2ff40 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 @@ -78,6 +78,16 @@ open class Entity>(val id: EntityID) { private val referenceCache by lazy { HashMap, Any?>() } + private val writeInnerTableLinkValues by lazy { HashMap, Any?>() } + + /** + * Returns the initial column-value mapping for an entity involved in an [InnerTableLink] relation + * before being flushed and inserted into the database. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ + open fun getInnerTableLinkValue(column: Column<*>): Any? = writeInnerTableLinkValues[column] + internal fun isNewEntity(): Boolean { val cache = TransactionManager.current().entityCache return cache.inserts[klass.table]?.contains(this) ?: false @@ -273,6 +283,7 @@ open class Entity>(val id: EntityID) { @Suppress("UNCHECKED_CAST", "USELESS_CAST") fun Column.lookup(): T = when { writeValues.containsKey(this as Column) -> writeValues[this as Column] as T + writeInnerTableLinkValues.containsKey(this) -> getInnerTableLinkValue(this) as T id._value == null && _readValues?.hasValue(this)?.not() ?: true -> defaultValueFun?.invoke() as T columnType.nullable -> readValues[this] else -> readValues[this]!! @@ -280,6 +291,10 @@ open class Entity>(val id: EntityID) { operator fun Column.setValue(o: Entity, desc: KProperty<*>, value: T) { klass.invalidateEntityInCache(o) + if (this !in klass.table.columns) { + writeInnerTableLinkValues[this] = value + return + } val currentValue = _readValues?.getOrNull(this) if (writeValues.containsKey(this as Column) || currentValue != value) { val entityCache = TransactionManager.current().entityCache @@ -337,14 +352,19 @@ open class Entity>(val id: EntityID) { * * @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. + * @param additionalColumns Any additional columns from the intermediate table that should be included when inserting. + * If left `null`, all columns additional to the [sourceColumn] and [targetColumn] will be included in the insert + * statement and will require a value if defaults are not defined. Provide an empty list as an argument if all + * additional columns should be ignored. * @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) + targetColumn: Column>, + additionalColumns: List>? = null + ) = InnerTableLink(sourceColumn.table, this@Entity.id.table, this@via, sourceColumn, targetColumn, additionalColumns) /** * Deletes this [Entity] instance, both from the cache and from the database. diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt index 9d8664f28c..58fc1dfa8d 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt @@ -23,6 +23,7 @@ class EntityCache(private val transaction: Transaction) { internal val inserts = LinkedHashMap, MutableSet>>() private val updates = LinkedHashMap, MutableSet>>() internal val referrers = HashMap, MutableMap, SizedIterable<*>>>() + private val innerTableLinks by lazy { LinkedHashMap, MutableMap>>() } /** * The amount of entities to store in this [EntityCache] per [Entity] class. @@ -55,7 +56,11 @@ class EntityCache(private val transaction: Transaction) { } } - private fun getMap(f: EntityClass<*, *>): MutableMap> = getMap(f.table) + private fun getMap(f: EntityClass<*, *>): MutableMap> = if (f is InnerTableLinkEntityClass<*, *>) { + innerTableLinks.getOrPut(f.table) { LimitedHashMap() } + } else { + getMap(f.table) + } private fun getMap(table: IdTable<*>): MutableMap> = data.getOrPut(table) { LimitedHashMap() @@ -293,6 +298,7 @@ class EntityCache(private val transaction: Transaction) { inserts.clear() updates.clear() clearReferrersCache() + innerTableLinks.clear() } /** Clears this [EntityCache] of stored data that maps cached parent entities to their referencing child entities. */ diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt index ef8793799f..16e9e1c254 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt @@ -20,6 +20,8 @@ import kotlin.reflect.KProperty * this will be inferred from the provided intermediate [table] columns. * @param _targetColumn The intermediate table's reference column for the parent entity class. If left `null`, * this will be inferred from the provided intermediate [table] columns. + * @param additionalColumns Any additional columns from the intermediate table that should be included when inserting. + * If left `null`, these will be inferred from the provided intermediate [table] columns. */ @Suppress("UNCHECKED_CAST") class InnerTableLink, Source : Entity, ID : Comparable, Target : Entity>( @@ -28,6 +30,7 @@ class InnerTableLink, Source : Entity, ID : Comparabl val target: EntityClass, _sourceColumn: Column>? = null, _targetColumn: Column>? = null, + additionalColumns: List>? = null, ) : ReadWriteProperty> { /** The list of columns and their [SortOrder] for ordering referred entities in many-to-many relationship. */ private val orderByExpressions: MutableList, SortOrder>> = mutableListOf() @@ -48,6 +51,11 @@ class InnerTableLink, Source : Entity, ID : Comparabl "Column $_sourceColumn point to wrong table, expected ${sourceTable.tableName}" } } + additionalColumns?.let { + require(it.all { column -> column.table == table }) { + "All additional columns should be from the same intermediate table ${table.tableName}" + } + } } /** The reference identity column for the child entity class. */ @@ -70,6 +78,9 @@ class InnerTableLink, Source : Entity, ID : Comparabl columns to entityTables } + private val additionalColumns = additionalColumns + ?: (table.columns - sourceColumn - targetColumn).filter { !it.columnType.isAutoInc } + override operator fun getValue(o: Source, unused: KProperty<*>): SizedIterable { if (o.id._value == null && !o.isNewEntity()) return emptySized() val transaction = TransactionManager.currentOrNull() @@ -110,11 +121,17 @@ class InnerTableLink, Source : Entity, ID : Comparabl entityCache.referrers[sourceColumn]?.remove(o.id) val targetIds = value.map { it.id } + val targetValues = value.map { target -> + target.id to additionalColumns.associateWith { target.getInnerTableLinkValue(it) } + } executeAsPartOfEntityLifecycle { table.deleteWhere { (sourceColumn eq o.id) and (targetColumn notInList targetIds) } - table.batchInsert(targetIds.filter { !existingIds.contains(it) }, shouldReturnGeneratedValues = false) { targetId -> + table.batchInsert(targetValues.filter { !existingIds.contains(it.first) }, shouldReturnGeneratedValues = false) { (targetId, additionalValues) -> this[sourceColumn] = o.id this[targetColumn] = targetId + additionalValues.forEach { (column, value) -> + this[column as Column] = value + } } } @@ -122,7 +139,9 @@ class InnerTableLink, Source : Entity, ID : Comparabl tx.registerChange(o.klass, o.id, EntityChangeType.Updated) // linked entities updated - val targetClass = (value.firstOrNull() ?: oldValue.firstOrNull())?.klass + val targetClass = (value.firstOrNull() ?: oldValue.firstOrNull())?.let { + (it as? InnerTableLinkEntity)?.wrapped?.klass ?: it.klass + } if (targetClass != null) { existingIds.plus(targetIds).forEach { tx.registerChange(targetClass, it, EntityChangeType.Updated) @@ -141,3 +160,51 @@ class InnerTableLink, Source : Entity, ID : Comparabl /** Modifies this reference to sort entities by a column specified in [expression] using ascending order. **/ infix fun orderBy(expression: Expression<*>) = orderBy(listOf(expression to SortOrder.ASC)) } + +/** + * Base class for an [Entity] instance identified by a [wrapped] entity comprised of any ID value. + * + * Instances of this base class should be used when needing to represent referenced entities in a many-to-many relation + * from fields defined using `via`, which require additional columns in the intermediate table. These additional + * columns should be added as constructor properties and the property-column mapping should be defined by + * [getInnerTableLinkValue]. + * + * @param WID ID type of the [wrapped] entity instance. + * @property wrapped The referenced (parent) entity whose unique ID value identifies this [InnerTableLinkEntity] instance. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ +abstract class InnerTableLinkEntity>(val wrapped: Entity) : Entity(wrapped.id) { + /** + * Returns the initial column-property mapping for an [InnerTableLinkEntity] instance + * before being flushed and inserted into the database. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ + abstract override fun getInnerTableLinkValue(column: Column<*>): Any? +} + +/** + * Base class representing the [EntityClass] that manages [InnerTableLinkEntity] instances and + * maintains their relation to the provided [table] of the wrapped entity. + * + * This should be used, as a companion object to [InnerTableLinkEntity], when needing to represent referenced entities + * in a many-to-many relation from fields defined using `via`, which require additional columns in the intermediate table. + * These additional columns will be retrieved as part of a queries [ResultRow] and the column-property mapping to create + * new instances should be defined by [createInstance]. + * + * @param WID ID type of the wrapped entity instance. + * @param E The [InnerTableLinkEntity] type that is managed by this class. + * @param [table] The [IdTable] object that stores rows mapped to the wrapped entity of this class. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ +abstract class InnerTableLinkEntityClass, out E : InnerTableLinkEntity>( + table: IdTable +) : EntityClass(table, null, null) { + /** + * Creates a new [InnerTableLinkEntity] instance by using the provided [row] to both create the wrapped entity + * and any additional columns. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ + abstract override fun createInstance(entityId: EntityID, row: ResultRow?): E +} diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt index b4672e3df4..04dd6dd555 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt @@ -1,8 +1,6 @@ package org.jetbrains.exposed.sql.tests.shared.entities import org.jetbrains.exposed.dao.* -import org.jetbrains.exposed.dao.id.CompositeID -import org.jetbrains.exposed.dao.id.CompositeIdTable import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.dao.id.IntIdTable @@ -291,46 +289,74 @@ class ViaTests : DatabaseTestsBase() { object Projects : IntIdTable("projects") { val name = varchar("name", 50) } - class Project(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Projects) var name by Projects.name - val tasks by Task via ProjectTasks + var tasks by TaskWithApproval via ProjectTasks } - object ProjectTasks : CompositeIdTable("project_tasks") { + object ProjectTasks : Table("project_tasks") { val project = reference("project", Projects, onDelete = ReferenceOption.CASCADE) val task = reference("task", Tasks, onDelete = ReferenceOption.CASCADE) val approved = bool("approved") + val sprint = integer("sprint") override val primaryKey = PrimaryKey(project, task) - - init { - addIdColumn(project) - addIdColumn(task) - } - } - - class ProjectTask(id: EntityID) : CompositeEntity(id) { - companion object : CompositeEntityClass(ProjectTasks) - - var approved by ProjectTasks.approved } object Tasks : IntIdTable("tasks") { val title = varchar("title", 64) } - class Task(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Tasks) var title by Tasks.title - val approved by ProjectTasks.approved + var projects by ProjectWithApproval via ProjectTasks + } + + class ProjectWithApproval( + val project: Project, + val approved: Boolean, + val sprint: Int + ) : InnerTableLinkEntity(project) { + companion object : InnerTableLinkEntityClass(Projects) { + override fun createInstance(entityId: EntityID, row: ResultRow?): ProjectWithApproval { + return row?.let { + ProjectWithApproval(Project.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: ProjectWithApproval(Project(entityId), false, 0) + } + } + + override fun getInnerTableLinkValue(column: Column<*>): Any = when (column) { + ProjectTasks.approved -> approved + ProjectTasks.sprint -> sprint + else -> error("Column does not exist in intermediate table") + } + } + + class TaskWithApproval( + val task: Task, + val approved: Boolean, + val sprint: Int + ) : InnerTableLinkEntity(task) { + companion object : InnerTableLinkEntityClass(Tasks) { + override fun createInstance(entityId: EntityID, row: ResultRow?): TaskWithApproval { + return row?.let { + TaskWithApproval(Task.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: TaskWithApproval(Task(entityId), false, 0) + } + } + + override fun getInnerTableLinkValue(column: Column<*>): Any = when (column) { + ProjectTasks.approved -> approved + ProjectTasks.sprint -> sprint + else -> error("Column does not exist in intermediate table") + } } @Test - fun testAdditionalLinkDataUsingCompositeIdInnerTable() { + fun testAdditionalLinkDataUsingInnerTableLinkEntities() { withTables(Projects, Tasks, ProjectTasks) { val p1 = Project.new { name = "Project 1" } val p2 = Project.new { name = "Project 2" } @@ -338,39 +364,44 @@ class ViaTests : DatabaseTestsBase() { val t2 = Task.new { title = "Task 2" } val t3 = Task.new { title = "Task 3" } - ProjectTask.new( - CompositeID { - it[ProjectTasks.task] = t1.id - it[ProjectTasks.project] = p1.id - } - ) { approved = true } - ProjectTask.new( - CompositeID { - it[ProjectTasks.task] = t2.id - it[ProjectTasks.project] = p2.id - } - ) { approved = false } - ProjectTask.new( - CompositeID { - it[ProjectTasks.task] = t3.id - it[ProjectTasks.project] = p2.id - } - ) { approved = false } + p1.tasks = SizedCollection(TaskWithApproval(t1, true, 1)) + p2.tasks = SizedCollection(TaskWithApproval(t2, true, 2), TaskWithApproval(t3, false, 3)) + + assertFalse(p1.tasks.single().approved) + p1.tasks = SizedCollection(TaskWithApproval(t1, true, 1)) + assertTrue(p1.tasks.single().approved) commit() + // test that all child entities set on the parent can be loaded by parent inTopLevelTransaction(Connection.TRANSACTION_SERIALIZABLE) { maxAttempts = 1 Project.all().with(Project::tasks) val cache = TransactionManager.current().entityCache - val p1Tasks = cache.getReferrers(p1.id, ProjectTasks.project)?.toList().orEmpty() - assertEqualLists(p1Tasks.map { it.id }, listOf(t1.id)) - assertTrue { p1Tasks.all { task -> task.approved } } + val p1Task = cache.getReferrers(p1.id, ProjectTasks.project)?.single() + assertEquals(t1.id, p1Task?.id) + assertEquals(t1.id, p1Task?.task?.id) + assertEquals(true, p1Task?.approved) + assertEquals(1, p1Task?.sprint) - val p2Tasks = cache.getReferrers(p2.id, ProjectTasks.project)?.toList().orEmpty() + val p2Tasks = cache.getReferrers(p2.id, ProjectTasks.project)?.toList().orEmpty() assertEqualLists(p2Tasks.map { it.id }, listOf(t2.id, t3.id)) - assertFalse { p1Tasks.all { task -> !task.approved } } + assertEqualLists(p2Tasks.map { it.approved }, listOf(true, false)) + assertEqualLists(p2Tasks.map { it.sprint }, listOf(2, 3)) + } + + // test that all parent entities can then be found by the child entity without setting again + inTopLevelTransaction(Connection.TRANSACTION_SERIALIZABLE) { + maxAttempts = 1 + Task.all().with(Task::projects) + val cache = TransactionManager.current().entityCache + + val t1Project = cache.getReferrers(t1.id, ProjectTasks.task)?.single() + assertEquals(p1.id, t1Project?.id) + assertEquals(p1.id, t1Project?.project?.id) + assertEquals(true, t1Project?.approved) + assertEquals(1, t1Project?.sprint) } } } From 41d9a75c793c5e668c9a587153f62ce6ed8969a0 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:53:37 -0400 Subject: [PATCH 2/5] feat!: EXPOSED-320 Many-to-many relation with extra columns - Remove approach to set/get additional data from an existing entity object field. This requires some UX concerns answered, for example, concerning caching. Would the wrapped entity (if loaded from a query of its own table) override the entity+data loaded from the many-to-many query? Would updating the field mean the reference should also be trigger a delete+insert? - Fix issue with updating and caching new additional data --- .../Writerside/topics/Breaking-Changes.md | 2 +- .../Writerside/topics/Deep-Dive-into-DAO.md | 31 ++----------- exposed-dao/api/exposed-dao.api | 1 - .../org/jetbrains/exposed/dao/Entity.kt | 15 ------- .../org/jetbrains/exposed/dao/EntityCache.kt | 2 +- .../jetbrains/exposed/dao/InnerTableLink.kt | 45 ++++++++++++++----- .../sql/tests/shared/entities/ViaTest.kt | 4 +- 7 files changed, 44 insertions(+), 56 deletions(-) diff --git a/documentation-website/Writerside/topics/Breaking-Changes.md b/documentation-website/Writerside/topics/Breaking-Changes.md index 2d7cd3d024..9e0b288d2d 100644 --- a/documentation-website/Writerside/topics/Breaking-Changes.md +++ b/documentation-website/Writerside/topics/Breaking-Changes.md @@ -28,7 +28,7 @@ This enables conversions from `null` to a non-nullable value, and vice versa. * In H2 the definition of json column with default value changed from `myColumn JSON DEFAULT '{"key": "value"}'` to `myColumn JSON DEFAULT JSON '{"key": "value"}'` * Additional columns from intermediate tables (defined for use with DAO `via()` for many-to-many relations) are no longer ignored on batch insert of references. - These columns are now included in the generated SQL and a value will be required when setting references, unless column defaults are defined. + These columns are now included and, unless column defaults are defined, values will be required when setting references by passing `InnerTableLinkEntity` instances. To continue to ignore these columns, use the non-infix version of `via()` and provide an empty list to `additionalColumns` (or a list of specific columns to include): ```kotlin diff --git a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md index b5db5f4238..91c4a058c1 100644 --- a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md +++ b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md @@ -321,7 +321,7 @@ class User(id: EntityID) : IntEntity(id) { ### Many-to-many reference In some cases, a many-to-many reference may be required. -Let's assume you want to add a reference to the following `Actors` table to the `StarWarsFilm` class: +Assuming that you want to add a reference to the following `Actors` table to the previous `StarWarsFilm` class: ```kotlin object Actors : IntIdTable() { val firstname = varchar("firstname", 50) @@ -364,7 +364,8 @@ Now you can access all actors (and their fields) for a `StarWarsFilm` object, `f film.actors.first() // returns an Actor object film.actors.map { it.lastname } // returns a List ``` -If the intermediate table is defined with more than just the two reference columns, these additional columns can be accessed in two ways, both detailed below. +If the intermediate table is defined with more than just the two reference columns, these additional columns can also be accessed by +calling `via()` on a special wrapping entity class, `InnerTableLinkEntity`, as shown below. Given a `StarWarsFilmActors` table with the extra column `roleName`: ```kotlin @@ -375,31 +376,7 @@ object StarWarsFilmActors : Table() { override val primaryKey = PrimaryKey(starWarsFilm, actor) } ``` -**The first approach** assumes that the value stored in this extra column will be accessed from the `Actor` class: -```kotlin -class Actor(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(Actors) - var firstname by Actors.firstname - var lastname by Actors.lastname - var roleName by StarWarsFilmActors.roleName -} -``` -This extra value can then be set, for example, when a new `Actor` is created or when it is provided to the parent entity's field, -and accessed like any other field: -```kotlin -val actor1 = Actor.new { - firstname = "Harrison" - lastname = "Ford" - roleName = "Han Solo" -} -// or -film.actors = SizedCollection(actor1, actor2.apply { roleName = "Ben Solo" }) - -StarWarsFilm.all().first.actors.map { it.roleName } -``` -**The second approach** assumes that the `Actor` class should not be given an extra field and that the extra value stored should -be accessed through an object that holds both the child entity and the additional data. - +The extra value stored can be accessed through an object that holds both the child entity and the additional data. To both allow this and still take advantage of the underlying DAO cache, a new entity class has to be defined using `InnerTableLinkEntity`, which details how to get and set the additional column values from the intermediate table through two overrides: ```kotlin diff --git a/exposed-dao/api/exposed-dao.api b/exposed-dao/api/exposed-dao.api index 8748958c32..c6f6bc4641 100644 --- a/exposed-dao/api/exposed-dao.api +++ b/exposed-dao/api/exposed-dao.api @@ -23,7 +23,6 @@ public class org/jetbrains/exposed/dao/Entity { public static synthetic fun flush$default (Lorg/jetbrains/exposed/dao/Entity;Lorg/jetbrains/exposed/dao/EntityBatchUpdate;ILjava/lang/Object;)Z public final fun getDb ()Lorg/jetbrains/exposed/sql/Database; public final fun getId ()Lorg/jetbrains/exposed/dao/id/EntityID; - public fun getInnerTableLinkValue (Lorg/jetbrains/exposed/sql/Column;)Ljava/lang/Object; public final fun getKlass ()Lorg/jetbrains/exposed/dao/EntityClass; public final fun getReadValues ()Lorg/jetbrains/exposed/sql/ResultRow; public final fun getValue (Lorg/jetbrains/exposed/dao/EntityFieldWithTransform;Lorg/jetbrains/exposed/dao/Entity;Lkotlin/reflect/KProperty;)Ljava/lang/Object; 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 d595f2ff40..328c3f9b91 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 @@ -78,16 +78,6 @@ open class Entity>(val id: EntityID) { private val referenceCache by lazy { HashMap, Any?>() } - private val writeInnerTableLinkValues by lazy { HashMap, Any?>() } - - /** - * Returns the initial column-value mapping for an entity involved in an [InnerTableLink] relation - * before being flushed and inserted into the database. - * - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval - */ - open fun getInnerTableLinkValue(column: Column<*>): Any? = writeInnerTableLinkValues[column] - internal fun isNewEntity(): Boolean { val cache = TransactionManager.current().entityCache return cache.inserts[klass.table]?.contains(this) ?: false @@ -283,7 +273,6 @@ open class Entity>(val id: EntityID) { @Suppress("UNCHECKED_CAST", "USELESS_CAST") fun Column.lookup(): T = when { writeValues.containsKey(this as Column) -> writeValues[this as Column] as T - writeInnerTableLinkValues.containsKey(this) -> getInnerTableLinkValue(this) as T id._value == null && _readValues?.hasValue(this)?.not() ?: true -> defaultValueFun?.invoke() as T columnType.nullable -> readValues[this] else -> readValues[this]!! @@ -291,10 +280,6 @@ open class Entity>(val id: EntityID) { operator fun Column.setValue(o: Entity, desc: KProperty<*>, value: T) { klass.invalidateEntityInCache(o) - if (this !in klass.table.columns) { - writeInnerTableLinkValues[this] = value - return - } val currentValue = _readValues?.getOrNull(this) if (writeValues.containsKey(this as Column) || currentValue != value) { val entityCache = TransactionManager.current().entityCache diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt index 58fc1dfa8d..ab5871d875 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt @@ -23,7 +23,7 @@ class EntityCache(private val transaction: Transaction) { internal val inserts = LinkedHashMap, MutableSet>>() private val updates = LinkedHashMap, MutableSet>>() internal val referrers = HashMap, MutableMap, SizedIterable<*>>>() - private val innerTableLinks by lazy { LinkedHashMap, MutableMap>>() } + internal val innerTableLinks by lazy { LinkedHashMap, MutableMap>>() } /** * The amount of entities to store in this [EntityCache] per [Entity] class. diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt index 16e9e1c254..2d81bd470d 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt @@ -78,8 +78,10 @@ class InnerTableLink, Source : Entity, ID : Comparabl columns to entityTables } - private val additionalColumns = additionalColumns - ?: (table.columns - sourceColumn - targetColumn).filter { !it.columnType.isAutoInc } + private val additionalColumns = (additionalColumns + ?: (table.columns - sourceColumn - targetColumn).filter { !it.columnType.isAutoInc }) + .takeIf { it.isEmpty() || target is InnerTableLinkEntityClass } + ?: error("Target entity must extend InnerTableLinkEntity to properly store and cache additional column data") override operator fun getValue(o: Source, unused: KProperty<*>): SizedIterable { if (o.id._value == null && !o.isNewEntity()) return emptySized() @@ -117,16 +119,33 @@ class InnerTableLink, Source : Entity, ID : Comparabl val entityCache = tx.entityCache entityCache.flush() val oldValue = getValue(o, unused) - val existingIds = oldValue.map { it.id }.toSet() + val existingValues = oldValue.mapIdToAdditionalValues() + val existingIds = existingValues.keys + val additionalColumnsExist = additionalColumns.isNotEmpty() + if (additionalColumnsExist) { + entityCache.innerTableLinks[target.table]?.remove(o.id.value) + } entityCache.referrers[sourceColumn]?.remove(o.id) - val targetIds = value.map { it.id } - val targetValues = value.map { target -> - target.id to additionalColumns.associateWith { target.getInnerTableLinkValue(it) } - } + val targetValues = value.mapIdToAdditionalValues() + val targetIds = targetValues.keys executeAsPartOfEntityLifecycle { - table.deleteWhere { (sourceColumn eq o.id) and (targetColumn notInList targetIds) } - table.batchInsert(targetValues.filter { !existingIds.contains(it.first) }, shouldReturnGeneratedValues = false) { (targetId, additionalValues) -> + val deleteCondition = if (additionalColumnsExist) { + val targetAdditionalValues = targetValues.map { it.value.values.toList() + it.key } + (sourceColumn eq o.id) and (additionalColumns + targetColumn notInList targetAdditionalValues) + } else { + (sourceColumn eq o.id) and (targetColumn notInList targetIds) + } + val newTargets = targetValues.filter { (targetId, additionalValues) -> + if (additionalColumnsExist) { + targetId !in existingIds || + existingValues[targetId]?.entries?.containsAll(additionalValues.entries) == false + } else { + targetId !in existingIds + } + } + table.deleteWhere { deleteCondition } + table.batchInsert(newTargets.entries, shouldReturnGeneratedValues = false) { (targetId, additionalValues) -> this[sourceColumn] = o.id this[targetColumn] = targetId additionalValues.forEach { (column, value) -> @@ -149,6 +168,12 @@ class InnerTableLink, Source : Entity, ID : Comparabl } } + private fun SizedIterable.mapIdToAdditionalValues(): Map, Map, Any?>> { + return associate { target -> + target.id to additionalColumns.associateWith { (target as InnerTableLinkEntity).getInnerTableLinkValue(it) } + } + } + /** Modifies this reference to sort entities based on multiple columns as specified in [order]. **/ infix fun orderBy(order: List, SortOrder>>) = this.also { orderByExpressions.addAll(order) @@ -180,7 +205,7 @@ abstract class InnerTableLinkEntity>(val wrapped: Entity): Any? + abstract fun getInnerTableLinkValue(column: Column<*>): Any? } /** diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt index 04dd6dd555..44370639c0 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt @@ -10,6 +10,8 @@ import org.jetbrains.exposed.sql.tests.DatabaseTestsBase import org.jetbrains.exposed.sql.tests.shared.assertEqualCollections import org.jetbrains.exposed.sql.tests.shared.assertEqualLists import org.jetbrains.exposed.sql.tests.shared.assertEquals +import org.jetbrains.exposed.sql.tests.shared.assertFalse +import org.jetbrains.exposed.sql.tests.shared.assertTrue import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.transactions.inTopLevelTransaction import org.junit.Test @@ -364,7 +366,7 @@ class ViaTests : DatabaseTestsBase() { val t2 = Task.new { title = "Task 2" } val t3 = Task.new { title = "Task 3" } - p1.tasks = SizedCollection(TaskWithApproval(t1, true, 1)) + p1.tasks = SizedCollection(TaskWithApproval(t1, false, 1)) p2.tasks = SizedCollection(TaskWithApproval(t2, true, 2), TaskWithApproval(t3, false, 3)) assertFalse(p1.tasks.single().approved) From 8adc0aac06162535c8a87382b08e87bd20287cbd Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:25:38 -0400 Subject: [PATCH 3/5] feat!: EXPOSED-320 Many-to-many relation with extra columns - Fix detekt issue --- .../main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt index 2d81bd470d..76aa8a4d93 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt @@ -78,8 +78,7 @@ class InnerTableLink, Source : Entity, ID : Comparabl columns to entityTables } - private val additionalColumns = (additionalColumns - ?: (table.columns - sourceColumn - targetColumn).filter { !it.columnType.isAutoInc }) + private val additionalColumns = (additionalColumns ?: (table.columns - sourceColumn - targetColumn).filter { !it.columnType.isAutoInc }) .takeIf { it.isEmpty() || target is InnerTableLinkEntityClass } ?: error("Target entity must extend InnerTableLinkEntity to properly store and cache additional column data") From a99b3f397a11678368efddd7e749706534ceedf9 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:43:13 -0400 Subject: [PATCH 4/5] feat!: EXPOSED-320 Many-to-many relation with extra columns - Add more tests (particularly for update) & rename test classes - Refactor cache to ensure no overlap with wrapped type. Each link entity is now stored by its target column, source column, and target id (stored in entity) - Move new entity classes to own file - Refactor logic for deleting cached entities --- .../Writerside/topics/Deep-Dive-into-DAO.md | 2 +- .../org/jetbrains/exposed/dao/EntityCache.kt | 26 +++-- .../org/jetbrains/exposed/dao/EntityClass.kt | 34 ++++++ .../exposed/dao/EntityLifecycleInterceptor.kt | 1 + .../jetbrains/exposed/dao/InnerTableLink.kt | 74 +++---------- .../exposed/dao/InnerTableLinkEntity.kt | 54 ++++++++++ .../sql/tests/shared/entities/ViaTest.kt | 100 ++++++++++++------ 7 files changed, 194 insertions(+), 97 deletions(-) create mode 100644 exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt diff --git a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md index 91c4a058c1..d7d79deed6 100644 --- a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md +++ b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md @@ -420,7 +420,7 @@ StarWarsFilm.all().first.actors.map { it.roleName } ``` If only some additional columns in the intermediate table should be used during batch insert, these can be specified by using -via() with an argument provided to additionalColumns. +via() with an argument provided to additionalColumns: class StarWarsFilm(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<StarWarsFilm>(StarWarsFilms) diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt index ab5871d875..9cb301c33a 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityCache.kt @@ -23,7 +23,9 @@ class EntityCache(private val transaction: Transaction) { internal val inserts = LinkedHashMap, MutableSet>>() private val updates = LinkedHashMap, MutableSet>>() internal val referrers = HashMap, MutableMap, SizedIterable<*>>>() - internal val innerTableLinks by lazy { LinkedHashMap, MutableMap>>() } + internal val innerTableLinks by lazy { + HashMap, MutableMap, MutableSet>>>() + } /** * The amount of entities to store in this [EntityCache] per [Entity] class. @@ -56,11 +58,7 @@ class EntityCache(private val transaction: Transaction) { } } - private fun getMap(f: EntityClass<*, *>): MutableMap> = if (f is InnerTableLinkEntityClass<*, *>) { - innerTableLinks.getOrPut(f.table) { LimitedHashMap() } - } else { - getMap(f.table) - } + private fun getMap(f: EntityClass<*, *>): MutableMap> = getMap(f.table) private fun getMap(table: IdTable<*>): MutableMap> = data.getOrPut(table) { LimitedHashMap() @@ -104,6 +102,14 @@ class EntityCache(private val transaction: Transaction) { */ fun , T : Entity> findAll(f: EntityClass): Collection = getMap(f).values as Collection + internal fun , ID : Comparable, T : InnerTableLinkEntity> findInnerTableLink( + targetColumn: Column>, + targetId: EntityID, + sourceId: EntityID + ): T? { + return innerTableLinks[targetColumn]?.get(sourceId)?.firstOrNull { it.id == targetId } as? T + } + /** Stores the specified [Entity] in this [EntityCache] using its associated [EntityClass] as the key. */ fun , T : Entity> store(f: EntityClass, o: T) { getMap(f)[o.id.value] = o @@ -118,6 +124,14 @@ class EntityCache(private val transaction: Transaction) { getMap(o.klass.table)[o.id.value] = o } + internal fun , ID : Comparable, T : InnerTableLinkEntity> storeInnerTableLink( + targetColumn: Column>, + sourceId: EntityID, + targetEntity: T + ) { + innerTableLinks.getOrPut(targetColumn) { HashMap() }.getOrPut(sourceId) { mutableSetOf() }.add(targetEntity) + } + /** Removes the specified [Entity] from this [EntityCache] using its associated [table] as the key. */ fun , T : Entity> remove(table: IdTable, o: T) { getMap(table).remove(o.id.value) 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 4a59a8e7a8..6c125b89de 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 @@ -163,6 +163,13 @@ abstract class EntityClass, out T : Entity>( with(entity) { col.lookup() }?.let { referrers.remove(it as EntityID<*>) } } } + cache.innerTableLinks.forEach { (_, links) -> + links.remove(entity.id) + + links.forEach { (_, targetEntities) -> + targetEntities.removeAll { it.wrapped == entity } + } + } } /** Returns a [SizedIterable] containing all entities with [EntityID] values from the provided [ids] list. */ @@ -207,6 +214,33 @@ abstract class EntityClass, out T : Entity>( wrapRow(it, alias) } + internal fun > wrapLinkRows( + rows: SizedIterable, + targetColumn: Column>, + sourceColumn: Column> + ): SizedIterable = rows mapLazy { wrapLinkRow(it, targetColumn, sourceColumn) } + + private fun > wrapLinkRow( + row: ResultRow, + targetColumn: Column>, + sourceColumn: Column> + ): T { + val targetId = row[table.id] + val sourceId = row[sourceColumn] + val transaction = TransactionManager.current() + val entity = transaction.entityCache.findInnerTableLink(targetColumn, targetId, sourceId) + ?: createInstance(targetId, row).also { new -> + new.klass = this + new.db = transaction.db + warmCache().storeInnerTableLink(targetColumn, sourceId, new as InnerTableLinkEntity) + } + if (entity._readValues == null) { + entity._readValues = row + } + + return entity + } + /** Wraps the specified [ResultRow] data into an [Entity] instance. */ @Suppress("MemberVisibilityCanBePrivate") fun wrapRow(row: ResultRow): T { 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 e1e6d8441a..ce66a16254 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 @@ -96,6 +96,7 @@ class EntityLifecycleInterceptor : GlobalStatementInterceptor { override fun beforeRollback(transaction: Transaction) { val entityCache = transaction.entityCache entityCache.clearReferrersCache() + entityCache.innerTableLinks.clear() entityCache.data.clear() entityCache.inserts.clear() } diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt index 76aa8a4d93..49fd4ecd61 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLink.kt @@ -90,12 +90,13 @@ class InnerTableLink, Source : Entity, ID : Comparabl val (columns, entityTables) = columnsAndTables val query = { - target.wrapRows( - @Suppress("SpreadOperator") - entityTables.select(columns) - .where { sourceColumn eq o.id } - .orderBy(*orderByExpressions.toTypedArray()) - ) + @Suppress("SpreadOperator") + val row = entityTables.select(columns) + .where { sourceColumn eq o.id } + .orderBy(*orderByExpressions.toTypedArray()) + (target as? InnerTableLinkEntityClass) + ?.wrapLinkRows(row, targetColumn, sourceColumn) as? SizedIterable + ?: target.wrapRows(row) } return transaction.entityCache.getOrPutReferrers(o.id, sourceColumn, query).also { o.storeReferenceInCache(sourceColumn, it) @@ -120,14 +121,19 @@ class InnerTableLink, Source : Entity, ID : Comparabl val oldValue = getValue(o, unused) val existingValues = oldValue.mapIdToAdditionalValues() val existingIds = existingValues.keys - val additionalColumnsExist = additionalColumns.isNotEmpty() - if (additionalColumnsExist) { - entityCache.innerTableLinks[target.table]?.remove(o.id.value) - } entityCache.referrers[sourceColumn]?.remove(o.id) val targetValues = value.mapIdToAdditionalValues() val targetIds = targetValues.keys + val additionalColumnsExist = additionalColumns.isNotEmpty() + if (additionalColumnsExist) { + entityCache.innerTableLinks[targetColumn]?.get(o.id)?.removeAll { cached -> + targetValues[cached.id]?.any { (column, targetValue) -> + cached.getInnerTableLinkValue(column) != targetValue + } != false + } + } + executeAsPartOfEntityLifecycle { val deleteCondition = if (additionalColumnsExist) { val targetAdditionalValues = targetValues.map { it.value.values.toList() + it.key } @@ -184,51 +190,3 @@ class InnerTableLink, Source : Entity, ID : Comparabl /** Modifies this reference to sort entities by a column specified in [expression] using ascending order. **/ infix fun orderBy(expression: Expression<*>) = orderBy(listOf(expression to SortOrder.ASC)) } - -/** - * Base class for an [Entity] instance identified by a [wrapped] entity comprised of any ID value. - * - * Instances of this base class should be used when needing to represent referenced entities in a many-to-many relation - * from fields defined using `via`, which require additional columns in the intermediate table. These additional - * columns should be added as constructor properties and the property-column mapping should be defined by - * [getInnerTableLinkValue]. - * - * @param WID ID type of the [wrapped] entity instance. - * @property wrapped The referenced (parent) entity whose unique ID value identifies this [InnerTableLinkEntity] instance. - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval - */ -abstract class InnerTableLinkEntity>(val wrapped: Entity) : Entity(wrapped.id) { - /** - * Returns the initial column-property mapping for an [InnerTableLinkEntity] instance - * before being flushed and inserted into the database. - * - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval - */ - abstract fun getInnerTableLinkValue(column: Column<*>): Any? -} - -/** - * Base class representing the [EntityClass] that manages [InnerTableLinkEntity] instances and - * maintains their relation to the provided [table] of the wrapped entity. - * - * This should be used, as a companion object to [InnerTableLinkEntity], when needing to represent referenced entities - * in a many-to-many relation from fields defined using `via`, which require additional columns in the intermediate table. - * These additional columns will be retrieved as part of a queries [ResultRow] and the column-property mapping to create - * new instances should be defined by [createInstance]. - * - * @param WID ID type of the wrapped entity instance. - * @param E The [InnerTableLinkEntity] type that is managed by this class. - * @param [table] The [IdTable] object that stores rows mapped to the wrapped entity of this class. - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval - */ -abstract class InnerTableLinkEntityClass, out E : InnerTableLinkEntity>( - table: IdTable -) : EntityClass(table, null, null) { - /** - * Creates a new [InnerTableLinkEntity] instance by using the provided [row] to both create the wrapped entity - * and any additional columns. - * - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval - */ - abstract override fun createInstance(entityId: EntityID, row: ResultRow?): E -} diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt new file mode 100644 index 0000000000..c847fd167e --- /dev/null +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt @@ -0,0 +1,54 @@ +package org.jetbrains.exposed.dao + +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IdTable +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ResultRow + +/** + * Base class for an [Entity] instance identified by a [wrapped] entity comprised of any ID value. + * + * Instances of this base class should be used when needing to represent referenced entities in a many-to-many relation + * from fields defined using `via`, which require additional columns in the intermediate table. These additional + * columns should be added as constructor properties and the property-column mapping should be defined by + * [getInnerTableLinkValue]. + * + * @param WID ID type of the [wrapped] entity instance. + * @property wrapped The referenced (parent) entity whose unique ID value identifies this [InnerTableLinkEntity] instance. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ +abstract class InnerTableLinkEntity>(val wrapped: Entity) : Entity(wrapped.id) { + /** + * Returns the initial column-property mapping for an [InnerTableLinkEntity] instance + * before being flushed and inserted into the database. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ + abstract fun getInnerTableLinkValue(column: Column<*>): Any? +} + +/** + * Base class representing the [EntityClass] that manages [InnerTableLinkEntity] instances and + * maintains their relation to the provided [table] of the wrapped entity. + * + * This should be used, as a companion object to [InnerTableLinkEntity], when needing to represent referenced entities + * in a many-to-many relation from fields defined using `via`, which require additional columns in the intermediate table. + * These additional columns will be retrieved as part of a query's [ResultRow] and the column-property mapping to create + * new instances should be defined by [createInstance]. + * + * @param WID ID type of the wrapped entity instance. + * @param E The [InnerTableLinkEntity] type that is managed by this class. + * @param [table] The [IdTable] object that stores rows mapped to the wrapped entity of this class. + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ +abstract class InnerTableLinkEntityClass, out E : InnerTableLinkEntity>( + table: IdTable +) : EntityClass(table, null, null) { + /** + * Creates a new [InnerTableLinkEntity] instance by using the provided [row] to both create the wrapped entity + * and any additional columns. + * + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + */ + abstract override fun createInstance(entityId: EntityID, row: ResultRow?): E +} diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt index 44370639c0..bdb375a352 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/ViaTest.kt @@ -18,8 +18,6 @@ import org.junit.Test import java.sql.Connection import java.util.* import kotlin.reflect.jvm.isAccessible -import kotlin.test.assertFalse -import kotlin.test.assertTrue object ViaTestData { object NumbersTable : UUIDTable() { @@ -288,14 +286,17 @@ class ViaTests : DatabaseTestsBase() { } } - object Projects : IntIdTable("projects") { + // IdTable without auto-increment is used so manual ids can be inserted without excluding SQL Server + object Projects : IdTable("projects") { + override val id = integer("id").entityId() val name = varchar("name", 50) + override val primaryKey = PrimaryKey(id) } class Project(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Projects) var name by Projects.name - var tasks by TaskWithApproval via ProjectTasks + var tasks by TaskWithData via ProjectTasks } object ProjectTasks : Table("project_tasks") { @@ -307,26 +308,29 @@ class ViaTests : DatabaseTestsBase() { override val primaryKey = PrimaryKey(project, task) } - object Tasks : IntIdTable("tasks") { + // IdTable without auto-increment is used so manual ids can be inserted without excluding SQL Server + object Tasks : IdTable("tasks") { + override val id = integer("id").entityId() val title = varchar("title", 64) + override val primaryKey = PrimaryKey(id) } class Task(id: EntityID) : IntEntity(id) { companion object : IntEntityClass(Tasks) var title by Tasks.title - var projects by ProjectWithApproval via ProjectTasks + var projects by ProjectWithData via ProjectTasks } - class ProjectWithApproval( + class ProjectWithData( val project: Project, val approved: Boolean, val sprint: Int ) : InnerTableLinkEntity(project) { - companion object : InnerTableLinkEntityClass(Projects) { - override fun createInstance(entityId: EntityID, row: ResultRow?): ProjectWithApproval { + companion object : InnerTableLinkEntityClass(Projects) { + override fun createInstance(entityId: EntityID, row: ResultRow?): ProjectWithData { return row?.let { - ProjectWithApproval(Project.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) - } ?: ProjectWithApproval(Project(entityId), false, 0) + ProjectWithData(Project.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: ProjectWithData(Project(entityId), false, 0) } } @@ -337,16 +341,16 @@ class ViaTests : DatabaseTestsBase() { } } - class TaskWithApproval( + class TaskWithData( val task: Task, val approved: Boolean, val sprint: Int ) : InnerTableLinkEntity(task) { - companion object : InnerTableLinkEntityClass(Tasks) { - override fun createInstance(entityId: EntityID, row: ResultRow?): TaskWithApproval { + companion object : InnerTableLinkEntityClass(Tasks) { + override fun createInstance(entityId: EntityID, row: ResultRow?): TaskWithData { return row?.let { - TaskWithApproval(Task.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) - } ?: TaskWithApproval(Task(entityId), false, 0) + TaskWithData(Task.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: TaskWithData(Task(entityId), false, 0) } } @@ -358,51 +362,83 @@ class ViaTests : DatabaseTestsBase() { } @Test - fun testAdditionalLinkDataUsingInnerTableLinkEntities() { + fun testAdditionalLinkDataInsertAndUpdate() { withTables(Projects, Tasks, ProjectTasks) { - val p1 = Project.new { name = "Project 1" } - val p2 = Project.new { name = "Project 2" } - val t1 = Task.new { title = "Task 1" } - val t2 = Task.new { title = "Task 2" } - val t3 = Task.new { title = "Task 3" } + val p1 = Project.new(123) { name = "Project 1" } + val p2 = Project.new(456) { name = "Project 2" } + val t1 = Task.new(11) { title = "Task 1" } + val t2 = Task.new(22) { title = "Task 2" } + val t3 = Task.new(33) { title = "Task 3" } - p1.tasks = SizedCollection(TaskWithApproval(t1, false, 1)) - p2.tasks = SizedCollection(TaskWithApproval(t2, true, 2), TaskWithApproval(t3, false, 3)) + p1.tasks = SizedCollection(TaskWithData(t1, false, 1)) + p2.tasks = SizedCollection(TaskWithData(t2, true, 2), TaskWithData(t1, false, 3)) assertFalse(p1.tasks.single().approved) - p1.tasks = SizedCollection(TaskWithApproval(t1, true, 1)) + p1.tasks = SizedCollection(TaskWithData(t1, true, 1)) assertTrue(p1.tasks.single().approved) + assertEqualCollections(p2.tasks.map { it.task.id }, listOf(t2.id, t1.id)) + p2.tasks = SizedCollection(TaskWithData(t2, true, 2), TaskWithData(t3, false, 3)) + assertEqualCollections(p2.tasks.map { it.task.id }, listOf(t2.id, t3.id)) + } + } + + @Test + fun testAdditionalLinkDataLoadedOnParent() { + withTables(Projects, Tasks, ProjectTasks) { + val p1 = Project.new(123) { name = "Project 1" } + val p2 = Project.new(456) { name = "Project 2" } + val t1 = Task.new(11) { title = "Task 1" } + val t2 = Task.new(22) { title = "Task 2" } + val t3 = Task.new(33) { title = "Task 3" } + + p1.tasks = SizedCollection(TaskWithData(t1, false, 1)) + p2.tasks = SizedCollection(TaskWithData(t2, true, 2), TaskWithData(t3, false, 3)) + commit() - // test that all child entities set on the parent can be loaded by parent inTopLevelTransaction(Connection.TRANSACTION_SERIALIZABLE) { maxAttempts = 1 Project.all().with(Project::tasks) val cache = TransactionManager.current().entityCache - val p1Task = cache.getReferrers(p1.id, ProjectTasks.project)?.single() + val p1Task = cache.getReferrers(p1.id, ProjectTasks.project)?.single() assertEquals(t1.id, p1Task?.id) assertEquals(t1.id, p1Task?.task?.id) - assertEquals(true, p1Task?.approved) + assertEquals(false, p1Task?.approved) assertEquals(1, p1Task?.sprint) - val p2Tasks = cache.getReferrers(p2.id, ProjectTasks.project)?.toList().orEmpty() + val p2Tasks = cache.getReferrers(p2.id, ProjectTasks.project)?.toList().orEmpty() assertEqualLists(p2Tasks.map { it.id }, listOf(t2.id, t3.id)) assertEqualLists(p2Tasks.map { it.approved }, listOf(true, false)) assertEqualLists(p2Tasks.map { it.sprint }, listOf(2, 3)) } + } + } + + @Test + fun testAdditionalLinkDataLoadedOnChild() { + withTables(Projects, Tasks, ProjectTasks) { + val p1 = Project.new(123) { name = "Project 1" } + val p2 = Project.new(456) { name = "Project 2" } + val t1 = Task.new(11) { title = "Task 1" } + val t2 = Task.new(22) { title = "Task 2" } + val t3 = Task.new(33) { title = "Task 3" } + + p1.tasks = SizedCollection(TaskWithData(t1, false, 1)) + p2.tasks = SizedCollection(TaskWithData(t2, true, 2), TaskWithData(t3, false, 3)) + + commit() - // test that all parent entities can then be found by the child entity without setting again inTopLevelTransaction(Connection.TRANSACTION_SERIALIZABLE) { maxAttempts = 1 Task.all().with(Task::projects) val cache = TransactionManager.current().entityCache - val t1Project = cache.getReferrers(t1.id, ProjectTasks.task)?.single() + val t1Project = cache.getReferrers(t1.id, ProjectTasks.task)?.single() assertEquals(p1.id, t1Project?.id) assertEquals(p1.id, t1Project?.project?.id) - assertEquals(true, t1Project?.approved) + assertEquals(false, t1Project?.approved) assertEquals(1, t1Project?.sprint) } } From 0562b39c20c380e86f5869c2ecbf67bb7748a126 Mon Sep 17 00:00:00 2001 From: Chantal Loncle <82039410+bog-walk@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:47:59 -0400 Subject: [PATCH 5/5] feat!: EXPOSED-320 Many-to-many relation with extra columns - Fix KDocs samples --- .../org/jetbrains/exposed/dao/InnerTableLinkEntity.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt index c847fd167e..6530602ff9 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/InnerTableLinkEntity.kt @@ -15,14 +15,14 @@ import org.jetbrains.exposed.sql.ResultRow * * @param WID ID type of the [wrapped] entity instance. * @property wrapped The referenced (parent) entity whose unique ID value identifies this [InnerTableLinkEntity] instance. - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithData */ abstract class InnerTableLinkEntity>(val wrapped: Entity) : Entity(wrapped.id) { /** * Returns the initial column-property mapping for an [InnerTableLinkEntity] instance * before being flushed and inserted into the database. * - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithData */ abstract fun getInnerTableLinkValue(column: Column<*>): Any? } @@ -39,7 +39,7 @@ abstract class InnerTableLinkEntity>(val wrapped: Entity, out E : InnerTableLinkEntity>( table: IdTable @@ -48,7 +48,7 @@ abstract class InnerTableLinkEntityClass, out E : InnerTab * Creates a new [InnerTableLinkEntity] instance by using the provided [row] to both create the wrapped entity * and any additional columns. * - * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithApproval + * @sample org.jetbrains.exposed.sql.tests.shared.entities.ViaTests.ProjectWithData */ abstract override fun createInstance(entityId: EntityID, row: ResultRow?): E }