Skip to content

feat: EXPOSED-462 Filtering on entity references #2472

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ class UserWithSingleRatingEntity(id: EntityID<Int>) : 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<Int>) : IntEntity(id) {
companion object : IntEntityClass<UserWithFiveStarRatingEntity>(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
}
23 changes: 23 additions & 0 deletions documentation-website/Writerside/topics/DAO-Relationships.topic
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@
user1.rating // returns a UserRating object
</code-block>
</chapter>
<chapter title="Filtering references" id="filtering-references">
<p>
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 <code>filter</code> method of collections:
</p>
<code-block lang="kotlin">
val fiveStarRatings
get() = ratings.filter { it.value > 5 }
</code-block>
<p>
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
<code>referrersOn</code> with a <code>View</code>:
</p>
<code-block lang="kotlin"
src="exposed-dao-relationships/src/main/kotlin/org/example/entities/UserEntity.kt"
include-symbol="UserWithFiveStarRatingEntity"
/>
<p>
By using a <code>View</code> you can add any conditions to the generated <code>WHERE</code> clause. However,
currently those relationships <b>do not support caching and eager loading</b> via <code>UserEntity::load(UserEntity::fiveStarRatings)</code>, so beware!
</p>
</chapter>
</chapter>
<chapter title="Optional reference" id="optional-reference">
<p>In Exposed, you can also add an optional reference.</p>
Expand Down
122 changes: 122 additions & 0 deletions exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,64 @@ abstract class EntityClass<ID : Any, out T : Entity<ID>>(
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 <TargetID : Any, Target : Entity<TargetID>, REF : Any> View<Target>.referrersOn(column: Column<REF>) =
registerRefRule(column) { ViewReferrers<ID, Entity<ID>, TargetID, Target, REF>(column, this, false) }

/**
* 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 <TargetID : Any, Target : Entity<TargetID>> View<Target>.referrersOn(
table: IdTable<*>
): ViewReferrers<ID, Entity<ID>, TargetID, Target, Any> {
val tableFK = [email protected](table)
val delegate = tableFK.from.first() as Column<Any>
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 <TargetID : Any, Target : Entity<TargetID>, REF : Any> View<Target>.referrersOn(
column: Column<REF>,
cache: Boolean
) =
registerRefRule(column) { ViewReferrers<ID, Entity<ID>, 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 <TargetID : Any, Target : Entity<TargetID>> View<Target>.referrersOn(
table: IdTable<*>,
cache: Boolean
): ViewReferrers<ID, Entity<ID>, TargetID, Target, Any> {
val tableFK = [email protected](table)
val delegate = tableFK.from.first() as Column<Any>
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.
Expand Down Expand Up @@ -716,6 +774,70 @@ abstract class EntityClass<ID : Any, out T : Entity<ID>>(
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 <TargetID : Any, Target : Entity<TargetID>, REF : Any> View<Target>.optionalReferrersOn(
column: Column<REF?>
) =
registerRefRule(column) { ViewReferrers<ID, Entity<ID>, TargetID, Target, REF?>(column, this, false) }

/**
* 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 <TargetID : Any, Target : Entity<TargetID>> View<Target>.optionalReferrersOn(
table: IdTable<*>
): ViewReferrers<ID, Entity<ID>, TargetID, Target, Any?> {
val tableFK = [email protected](table)
val delegate = tableFK.from.first() as Column<Any?>
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 <TargetID : Any, Target : Entity<TargetID>, REF : Any> View<Target>.optionalReferrersOn(
column: Column<REF?>,
cache: Boolean = false
) =
registerRefRule(column) { ViewReferrers<ID, Entity<ID>, 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 <TargetID : Any, Target : Entity<TargetID>> View<Target>.optionalReferrersOn(
table: IdTable<*>,
cache: Boolean = false
): ViewReferrers<ID, Entity<ID>, TargetID, Target, Any?> {
val tableFK = [email protected](table)
val delegate = tableFK.from.first() as Column<Any?>
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`.
Expand Down
80 changes: 53 additions & 27 deletions exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/References.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,35 +117,10 @@ open class Referrers<ParentID : Any, in Parent : Entity<ParentID>, 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<Child> {
val isSingleIdReference = hasSingleReferenceWithReferee(allReferences)
val value: REF = thisRef.run {
if (isSingleIdReference) {
val refereeColumn = reference.referee<REF>()!!
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<EntityID<Any>>].value
EqOp(child, child.wrap((parentValue as? DaoEntityID<*>)?.value ?: parentValue))
}.compoundAnd()
}
val query = {
@Suppress("SpreadOperator")
factory
Expand Down Expand Up @@ -173,6 +148,38 @@ open class Referrers<ParentID : Any, in Parent : Entity<ParentID>, ChildID : Any
}
}

/** Builds the condition that will be used to filter child entities. */
@Suppress("UNCHECKED_CAST", "NestedBlockDepth")
protected open fun buildFindCondition(thisRef: Parent): Op<Boolean>? {
val isSingleIdReference = hasSingleReferenceWithReferee(allReferences)
val value: REF = thisRef.run {
if (isSingleIdReference) {
val refereeColumn = reference.referee<REF>()!!
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<EntityID<Any>>].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<Pair<Expression<*>, SortOrder>>) = this.also {
orderByExpressions.addAll(order)
Expand Down Expand Up @@ -208,6 +215,25 @@ class OptionalReferrers<ParentID : Any, in Parent : Entity<ParentID>, ChildID :
references: Map<Column<*>, Column<*>>? = null
) : Referrers<ParentID, Parent, ChildID, Child, REF?>(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<ParentID : Any, in Parent : Entity<ParentID>, ChildID : Any, out Child : Entity<ChildID>, REF>(
reference: Column<REF>,
private val view: View<Child>,
cache: Boolean,
references: Map<Column<*>, Column<*>>? = null
) : Referrers<ParentID, Parent, ChildID, Child, REF>(reference, view.factory as EntityClass<ChildID, Child>, cache, references) {

override fun buildFindCondition(thisRef: Parent): Op<Boolean>? = super.buildFindCondition(thisRef)?.and(view.op)
}

private fun <SRC : Entity<*>> getReferenceObjectFromDelegatedProperty(entity: SRC, property: KProperty1<SRC, Any?>): Any? {
property.isAccessible = true
return property.getDelegate(entity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class City(id: EntityID<Int>) : 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() {
Expand Down Expand Up @@ -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 }}")
}
}

Expand Down
Loading