diff --git a/documentation-website/Writerside/topics/Breaking-Changes.md b/documentation-website/Writerside/topics/Breaking-Changes.md index c762298453..9e0b288d2d 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 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 +// 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..d7d79deed6 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: +Assuming that you want to add a reference to the following `Actors` table to the previous `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,96 @@ 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 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 +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 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 +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..c6f6bc4641 100644 --- a/exposed-dao/api/exposed-dao.api +++ b/exposed-dao/api/exposed-dao.api @@ -43,8 +43,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 +237,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 +252,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..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 @@ -337,14 +337,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..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,6 +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 { + HashMap, MutableMap, MutableSet>>>() + } /** * The amount of entities to store in this [EntityCache] per [Entity] class. @@ -99,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 @@ -113,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) @@ -293,6 +312,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/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 ef8793799f..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 @@ -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,10 @@ class InnerTableLink, Source : Entity, ID : Comparabl columns to entityTables } + 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() val transaction = TransactionManager.currentOrNull() @@ -78,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) @@ -106,15 +119,43 @@ 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 entityCache.referrers[sourceColumn]?.remove(o.id) - val targetIds = value.map { it.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 { - table.deleteWhere { (sourceColumn eq o.id) and (targetColumn notInList targetIds) } - table.batchInsert(targetIds.filter { !existingIds.contains(it) }, shouldReturnGeneratedValues = false) { targetId -> + 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) -> + this[column as Column] = value + } } } @@ -122,7 +163,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) @@ -130,6 +173,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) 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..6530602ff9 --- /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.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.ProjectWithData + */ + 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.ProjectWithData + */ +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.ProjectWithData + */ + 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..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 @@ -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 @@ -12,14 +10,14 @@ 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 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,74 +286,114 @@ 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 - val tasks by Task via ProjectTasks + var tasks by TaskWithData 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") { + // 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 - val approved by ProjectTasks.approved + var projects by ProjectWithData via ProjectTasks + } + + class ProjectWithData( + val project: Project, + val approved: Boolean, + val sprint: Int + ) : InnerTableLinkEntity(project) { + companion object : InnerTableLinkEntityClass(Projects) { + override fun createInstance(entityId: EntityID, row: ResultRow?): ProjectWithData { + return row?.let { + ProjectWithData(Project.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: ProjectWithData(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 TaskWithData( + val task: Task, + val approved: Boolean, + val sprint: Int + ) : InnerTableLinkEntity(task) { + companion object : InnerTableLinkEntityClass(Tasks) { + override fun createInstance(entityId: EntityID, row: ResultRow?): TaskWithData { + return row?.let { + TaskWithData(Task.wrapRow(it), it[ProjectTasks.approved], it[ProjectTasks.sprint]) + } ?: TaskWithData(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 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" } - - 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 } + 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(t1, false, 3)) + + assertFalse(p1.tasks.single().approved) + 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() @@ -364,13 +402,44 @@ class ViaTests : DatabaseTestsBase() { 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(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)) - assertFalse { p1Tasks.all { task -> !task.approved } } + 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() + + 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(false, t1Project?.approved) + assertEquals(1, t1Project?.sprint) } } }