diff --git a/documentation-website/Writerside/topics/Breaking-Changes.md b/documentation-website/Writerside/topics/Breaking-Changes.md index 9616aa4816..c3a447cf1d 100644 --- a/documentation-website/Writerside/topics/Breaking-Changes.md +++ b/documentation-website/Writerside/topics/Breaking-Changes.md @@ -3,6 +3,22 @@ ## 0.54.0 * All objects that are part of the sealed class `ForUpdateOption` are now converted to `data object`. +* 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.51.0 diff --git a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md index c8c6402d3f..9a55afba24 100644 --- a/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md +++ b/documentation-website/Writerside/topics/Deep-Dive-into-DAO.md @@ -299,15 +299,15 @@ class User(id: EntityID) : IntEntity(id) { ... ``` -### many-to-many reference +### 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 @@ -325,21 +325,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 69bd31f82a..c6e515c35b 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 873535b620..38985a554e 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 90a611c2e1..ba8cb3ccbe 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 @@ -18,8 +16,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() { @@ -291,43 +287,134 @@ class ViaTests : DatabaseTestsBase() { object Projects : IntIdTable("projects") { val name = varchar("name", 50) } - class Project(id: EntityID) : IntEntity(id) { - companion object : IntEntityClass(Projects) + class ProjectUsingEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Projects) var name by Projects.name - val tasks by Task via ProjectTasks + var tasks by TaskUsingEntity 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) - } + object Tasks : IntIdTable("tasks") { + val title = varchar("title", 64) } - class ProjectTask(id: EntityID) : CompositeEntity(id) { - companion object : CompositeEntityClass(ProjectTasks) + class TaskUsingEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Tasks) + var title by Tasks.title var approved by ProjectTasks.approved + var sprint by ProjectTasks.sprint } - object Tasks : IntIdTable("tasks") { - val title = varchar("title", 64) + @Test + fun testAdditionalLinkDataUsingOriginalEntities() { + withTables(Projects, Tasks, ProjectTasks) { + val p1 = ProjectUsingEntity.new { name = "Project 1" } + val p2 = ProjectUsingEntity.new { name = "Project 2" } + val t1 = TaskUsingEntity.new { title = "Task 1" } + // additional fields can be set in new() + val t2 = TaskUsingEntity.new { + title = "Task 2" + approved = true + sprint = 2 + } + val t3 = TaskUsingEntity.new { + title = "Task 3" + approved = false + sprint = 3 + } + + // or additional fields can be applied directly once setting the parent reference + p1.tasks = SizedCollection( + t1.apply { + approved = true + sprint = 1 + } + ) + p2.tasks = SizedCollection(t2, t3) + + commit() + + inTopLevelTransaction(Connection.TRANSACTION_SERIALIZABLE) { + maxAttempts = 1 + ProjectUsingEntity.all().with(ProjectUsingEntity::tasks) + val cache = TransactionManager.current().entityCache + + val p1Task = cache.getReferrers(p1.id, ProjectTasks.project)?.single() + assertEquals(t1.id, p1Task?.id) + assertEquals(true, p1Task?.approved) + assertEquals(1, p1Task?.sprint) + + 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)) + } + } } + + class Project(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Projects) + + var name by Projects.name + var tasks by TaskWithApproval 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 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 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" } @@ -335,39 +422,40 @@ 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)) 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) } } }