From f36f608ddd4d3ccf3eac204a16b0aaf47d5a93d2 Mon Sep 17 00:00:00 2001 From: Lina Date: Sun, 27 Apr 2025 23:45:18 +0300 Subject: [PATCH 1/5] feat: Initial impl of referrersOn for views Now it's possible to additionally filter the child entities on a one-to-many relationship by using a `View` as the left-hand-side parameter for `referrersOn`: val kids by User.view { Users.age less 18 } referrersOn Users.city This example was added to SamplesDao to show off the new feature. FIXME: tried adding eager loading but it seems like there's no way to filter entities based on the Op, so might for now be impossible. TODO: write tests --- .../org/jetbrains/exposed/dao/EntityClass.kt | 58 ++++++++++++++ .../org/jetbrains/exposed/dao/References.kt | 80 ++++++++++++------- .../exposed/sql/tests/demo/dao/SamplesDao.kt | 2 + 3 files changed, 113 insertions(+), 27 deletions(-) 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 87690dfb29..9ee423b339 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 @@ -640,6 +640,64 @@ abstract class EntityClass>( return registerRefRule(delegate) { Referrers(delegate, this, cache, tableFK.references) } } + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of child + * objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + * + * By default, this also stores the loaded entities to a cache. + */ + infix fun , REF : Any> View.referrersOn(column: Column) = + registerRefRule(column) { ViewReferrers, TargetID, Target, REF>(column, this, true) } + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + */ + infix fun > View.referrersOn( + table: IdTable<*> + ): ViewReferrers, TargetID, Target, Any> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return registerRefRule(delegate) { ViewReferrers(delegate, this, true, tableFK.references) } + } + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a [column] using `reference()` on the child table. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun , REF : Any> View.referrersOn( + column: Column, + cache: Boolean + ) = + registerRefRule(column) { ViewReferrers, TargetID, Target, REF>(column, this, cache) } + + /** + * Registers a reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun > View.referrersOn( + table: IdTable<*>, + cache: Boolean + ): ViewReferrers, TargetID, Target, Any> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return registerRefRule(delegate) { ViewReferrers(delegate, this, cache, tableFK.references) } + } + /** * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of * child objects of this `EntityClass` that all reference the parent. diff --git a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/References.kt b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/References.kt index 46eecce116..2e36f24430 100644 --- a/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/References.kt +++ b/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/References.kt @@ -117,35 +117,10 @@ open class Referrers, ChildID : Any mapOf(reference as Column<*> to reference.referee!!) } - @Suppress("UNCHECKED_CAST", "NestedBlockDepth") + @Suppress("UNCHECKED_CAST") override operator fun getValue(thisRef: Parent, property: KProperty<*>): SizedIterable { - val isSingleIdReference = hasSingleReferenceWithReferee(allReferences) - val value: REF = thisRef.run { - if (isSingleIdReference) { - val refereeColumn = reference.referee()!! - val refereeValue = refereeColumn.lookup() - when { - reference.columnType !is EntityIDColumnType<*> && refereeColumn.columnType is EntityIDColumnType<*> -> - (refereeValue as? EntityID<*>)?.let { it.value as? REF } ?: refereeValue - else -> refereeValue - } - } else { - getCompositeID { - allReferences.map { (_, parent) -> parent to parent.lookup() } - } as REF - } - } - if (thisRef.id._value == null || value == null) return emptySized() + val condition = buildFindCondition(thisRef) ?: return emptySized() - val condition = if (isSingleIdReference) { - reference eq value - } else { - value as CompositeID - allReferences.map { (child, parent) -> - val parentValue = value[parent as Column>].value - EqOp(child, child.wrap((parentValue as? DaoEntityID<*>)?.value ?: parentValue)) - }.compoundAnd() - } val query = { @Suppress("SpreadOperator") factory @@ -173,6 +148,38 @@ open class Referrers, ChildID : Any } } + /** Builds the condition that will be used to filter child entities. */ + @Suppress("UNCHECKED_CAST", "NestedBlockDepth") + protected open fun buildFindCondition(thisRef: Parent): Op? { + val isSingleIdReference = hasSingleReferenceWithReferee(allReferences) + val value: REF = thisRef.run { + if (isSingleIdReference) { + val refereeColumn = reference.referee()!! + val refereeValue = refereeColumn.lookup() + when { + reference.columnType !is EntityIDColumnType<*> && refereeColumn.columnType is EntityIDColumnType<*> -> + (refereeValue as? EntityID<*>)?.let { it.value as? REF } ?: refereeValue + else -> refereeValue + } + } else { + getCompositeID { + allReferences.map { (_, parent) -> parent to parent.lookup() } + } as REF + } + } + if (thisRef.id._value == null || value == null) return null + + return if (isSingleIdReference) { + reference eq value + } else { + value as CompositeID + allReferences.map { (child, parent) -> + val parentValue = value[parent as Column>].value + EqOp(child, child.wrap((parentValue as? DaoEntityID<*>)?.value ?: parentValue)) + }.compoundAnd() + } + } + /** 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) @@ -208,6 +215,25 @@ class OptionalReferrers, ChildID : references: Map, Column<*>>? = null ) : Referrers(reference, factory, cache, references) +/** + * Class responsible for implementing property delegates of the read-only properties involved in a filtered one-to-many + * relation, which retrieves only those child entities that both are referenced by the parent entity and match the + * condition specified in the given [view]. + * + * @param reference The reference column defined on the child entity's associated table. + * @param view The [View] that defines the additional conditions child entities should match. + * @param cache Whether loaded reference entities should be stored in the [EntityCache]. + */ +class ViewReferrers, ChildID : Any, out Child : Entity, REF>( + reference: Column, + private val view: View, + cache: Boolean, + references: Map, Column<*>>? = null +) : Referrers(reference, view.factory as EntityClass, cache, references) { + + override fun buildFindCondition(thisRef: Parent): Op? = super.buildFindCondition(thisRef)?.and(view.op) +} + private fun > getReferenceObjectFromDelegatedProperty(entity: SRC, property: KProperty1): Any? { property.isAccessible = true return property.getDelegate(entity) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/demo/dao/SamplesDao.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/demo/dao/SamplesDao.kt index 3973c4620b..1bee6cac17 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/demo/dao/SamplesDao.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/demo/dao/SamplesDao.kt @@ -32,6 +32,7 @@ class City(id: EntityID) : IntEntity(id) { var name by Cities.name val users by User referrersOn Users.city + val kids by User.view { Users.age less 18 } referrersOn Users.city } fun main() { @@ -72,6 +73,7 @@ fun main() { println("Cities: ${City.all().joinToString { it.name }}") println("Users in ${stPete.name}: ${stPete.users.joinToString { it.name }}") println("Adults: ${User.find { Users.age greaterEq 18 }.joinToString { it.name }}") + println("Kids in ${stPete.name}: ${stPete.kids.joinToString { it.name }}") } } From ac6443bce05f92c1db1e2efb3100c8476cbfc795 Mon Sep 17 00:00:00 2001 From: Lina Date: Mon, 28 Apr 2025 00:21:55 +0300 Subject: [PATCH 2/5] feat: optionalReferrersOn for views TODO: write tests --- .../org/jetbrains/exposed/dao/EntityClass.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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 9ee423b339..bcb989d5a3 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 @@ -774,6 +774,70 @@ abstract class EntityClass>( return registerRefRule(delegate) { Referrers(delegate, this, cache, tableFK.references) } } + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a [column] using either `optReference()` or + * reference().nullable()` on the child table. + * + * By default, this also stores the loaded entities to a cache. + */ + infix fun , REF : Any> View.optionalReferrersOn( + column: Column + ) = + registerRefRule(column) { ViewReferrers, TargetID, Target, REF?>(column, this, true) } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + * + * By default, this also stores the loaded entities to a cache. + */ + infix fun > View.optionalReferrersOn( + table: IdTable<*> + ): ViewReferrers, TargetID, Target, Any?> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return registerRefRule(delegate) { ViewReferrers(delegate, this, true, tableFK.references) } + } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a [column] using either `optReference()` or + * `reference().nullable()` on the child table. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun , REF : Any> View.optionalReferrersOn( + column: Column, + cache: Boolean = false + ) = + registerRefRule(column) { ViewReferrers, TargetID, Target, REF?>(column, this, cache) } + + /** + * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of + * child objects of this `EntityClass` that all reference the parent and match the conditions in the view. + * + * The reference should have been defined by the creation of a foreign key constraint on the child table, + * by using `foreignKey()`. + * + * Set [cache] to `true` to also store the loaded entities to a cache. + */ + fun > View.optionalReferrersOn( + table: IdTable<*>, + cache: Boolean = false + ): ViewReferrers, TargetID, Target, Any?> { + val tableFK = this@EntityClass.getCompositeForeignKey(table) + val delegate = tableFK.from.first() as Column + return registerRefRule(delegate) { ViewReferrers(delegate, this, cache, tableFK.references) } + } + /** * Returns the child table's [ForeignKeyConstraint] that matches the primary key columns defined on the table * associated with this `EntityClass`. From 1a31dfe432af9ace72a5514b211b29abf187ea13 Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 1 May 2025 15:21:20 +0300 Subject: [PATCH 3/5] docs: Add chapter about filtering referrersOn with views --- .../kotlin/org/example/entities/UserEntity.kt | 9 ++++++++ .../Writerside/topics/DAO-Relationships.topic | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/documentation-website/Writerside/snippets/exposed-dao-relationships/src/main/kotlin/org/example/entities/UserEntity.kt b/documentation-website/Writerside/snippets/exposed-dao-relationships/src/main/kotlin/org/example/entities/UserEntity.kt index 4db59a15bb..8139de5479 100644 --- a/documentation-website/Writerside/snippets/exposed-dao-relationships/src/main/kotlin/org/example/entities/UserEntity.kt +++ b/documentation-website/Writerside/snippets/exposed-dao-relationships/src/main/kotlin/org/example/entities/UserEntity.kt @@ -46,3 +46,12 @@ class UserWithSingleRatingEntity(id: EntityID) : IntEntity(id) { var name by UsersTable.name val rating by UserRatingEntity backReferencedOn UserRatingsTable.user // make sure to use val and backReferencedOn } + +class UserWithFiveStarRatingEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(UsersTable) + + var name by UsersTable.name + val ratings by UserRatingEntity referrersOn UserRatingsTable.user + + val fiveStarRatings by UserRatingEntity.view { UserRatingsTable.value greaterEq 5 } referrersOn UserRatingsTable.user +} diff --git a/documentation-website/Writerside/topics/DAO-Relationships.topic b/documentation-website/Writerside/topics/DAO-Relationships.topic index 5cedbe5427..d861a3f16f 100644 --- a/documentation-website/Writerside/topics/DAO-Relationships.topic +++ b/documentation-website/Writerside/topics/DAO-Relationships.topic @@ -122,6 +122,29 @@ user1.rating // returns a UserRating object + +

+ Suppose you want to additionally filter a user's ratings to only show the films they rated 5 or more. + You could to this with a regular Kotlin property and the filter method of collections: +

+ + val fiveStarRatings + get() = ratings.filter { it.value > 5 } + +

+ This has a slight problem: even though we only use a fraction of the user's ratings, we still have to + fetch all of them from the database. A much better choice would be to add another property using + referrersOn with a View: +

+ +

+ By using a View you can add any conditions to the generated WHERE clause. However, + currently those relationships do not support caching and eager loading via UserEntity::load(UserEntity::fiveStarRatings), so beware! +

+

In Exposed, you can also add an optional reference.

From ca822f90f03cf81c04fe8a7c41e9bc4c10b5eb34 Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 1 May 2025 16:02:00 +0300 Subject: [PATCH 4/5] fix: Disable caching for filtered references by default I don't know how to actually make the caching work there. :( Seems like we'd have to evaluate the custom WHERE clause from the View and filter based on that, but it doens't look like there's any way to evaluate a Op. --- .../src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 bcb989d5a3..4741b871e4 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 @@ -649,7 +649,7 @@ abstract class EntityClass>( * By default, this also stores the loaded entities to a cache. */ infix fun , REF : Any> View.referrersOn(column: Column) = - registerRefRule(column) { ViewReferrers, TargetID, Target, REF>(column, this, true) } + registerRefRule(column) { ViewReferrers, TargetID, Target, REF>(column, this, false) } /** * Registers a reference as an immutable field of the parent entity class, which returns a collection of @@ -786,7 +786,7 @@ abstract class EntityClass>( infix fun , REF : Any> View.optionalReferrersOn( column: Column ) = - registerRefRule(column) { ViewReferrers, TargetID, Target, REF?>(column, this, true) } + registerRefRule(column) { ViewReferrers, TargetID, Target, REF?>(column, this, false) } /** * Registers an optional reference as an immutable field of the parent entity class, which returns a collection of From 5a4ff2ecc836278bbf82c290f35d4ecfa2af2f1d Mon Sep 17 00:00:00 2001 From: Lina Date: Thu, 1 May 2025 16:02:11 +0300 Subject: [PATCH 5/5] fix: Tests for filtered references --- .../sql/tests/shared/entities/EntityTests.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityTests.kt index 80fd5c2929..16c1f3418e 100644 --- a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityTests.kt +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/entities/EntityTests.kt @@ -19,6 +19,7 @@ import org.jetbrains.exposed.sql.vendors.OracleDialect import org.junit.Test import java.sql.Connection import java.util.* +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -1777,4 +1778,116 @@ class EntityTests : DatabaseTestsBase() { flushCache() } } + + object HumansWithAge : IntIdTable() { + val name = varchar("name", 255) + val age = integer("age") + + val parent = optReference("parent", HumansWithAge) + } + + class HumanWithAge(id: EntityID) : IntEntity(id) { + var name by HumansWithAge.name + var age by HumansWithAge.age + + var parent by HumanWithAge optionalReferencedOn HumansWithAge.parent + val children by HumanWithAge optionalReferrersOn HumansWithAge.parent + + val underageChildren by HumanWithAge.view { HumansWithAge.age less 18 } optionalReferrersOn HumansWithAge.parent + + companion object : IntEntityClass(HumansWithAge) + } + + @Test + fun testOptionalFilteredReferences() { + withTables(HumansWithAge) { + val parent = HumanWithAge.new { + name = "Human 1" + age = 45 + } + + val child1 = HumanWithAge.new { + this.parent = parent + name = "Child 1" + age = 20 + } + + val child2 = HumanWithAge.new { + this.parent = parent + name = "Child 2" + age = 2 + } + + commit() + + assertContains(parent.children, child1) + assertContains(parent.children, child2) + + assertContains(parent.underageChildren, child2) + assertFalse(parent.underageChildren.contains(child1)) + } + } + + object Directors : IntIdTable() { + val name = varchar("name", 255) + } + + object Films : IntIdTable() { + val title = varchar("title", 255) + val metacriticScore = integer("metacritic_score") + val director = reference("director", Directors) + } + + class Director(id: EntityID) : IntEntity(id) { + var name by Directors.name + + val films by Film referrersOn Films.director + val goodFilms by Film.view { Films.metacriticScore greaterEq 70 } referrersOn Films.director + + companion object : IntEntityClass(Directors) + } + + class Film(id: EntityID) : IntEntity(id) { + var title by Films.title + var metacriticScore by Films.metacriticScore + var director by Director referencedOn Films.director + + companion object : IntEntityClass(Films) + } + + @Test + fun testFilteredReferences() { + withTables(Directors, Films) { + val director = Director.new { name = "Steven Spielberg" } + val otherDirector = Director.new { name = "Cristopher Nolan" } + + // Good film according to some Reddit answer. And Metacritic. + val goodFilm = Film.new { + title = "Saving Private Ryan" + metacriticScore = 91 + this.director = director + } + + val badFilm = Film.new { + title = "1941" + metacriticScore = 34 + this.director = director + } + + val someoneElsesFilm = Film.new { + title = "Interstellar" + metacriticScore = 74 + this.director = otherDirector + } + + commit() + + assertContains(director.films, goodFilm) + assertContains(director.films, badFilm) + + assertContains(director.goodFilms, goodFilm) + assertFalse(director.goodFilms.contains(badFilm)) + assertFalse(director.goodFilms.contains(someoneElsesFilm)) + } + } }