Skip to content

Commit

Permalink
feat: EXPOSED-403 Implement EntityClass.restriction
Browse files Browse the repository at this point in the history
- A means to apply a broad filter for an entire entity/entity class
- can typically be used for soft delete scenarios
  • Loading branch information
bystam committed Jun 5, 2024
1 parent 5ad1981 commit 1e71160
Show file tree
Hide file tree
Showing 2 changed files with 65 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ abstract class EntityClass<ID : Comparable<ID>, out T : Entity<ID>>(
*
* @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.testNonEntityIdReference
*/
open fun all(): SizedIterable<T> = wrapRows(table.selectAll().notForUpdate())
open fun all(): SizedIterable<T> = wrapRows(table.selectAll().addRestriction().notForUpdate())

/**
* Gets all the [Entity] instances that conform to the [op] conditional expression.
Expand Down Expand Up @@ -313,12 +313,32 @@ abstract class EntityClass<ID : Comparable<ID>, out T : Entity<ID>>(
/** The columns that this [EntityClass] depends on when maintaining relations with managed [Entity] instances. */
open val dependsOnColumns: List<Column<out Any?>> get() = dependsOnTables.columns

/**
* Base SQL predicate automatically applied to all fetch queries. If implemented, it will
* behave as an automatic `AND <restriction>` addition to any queries in this EntityClass.
*
* Can be overridden to, for instance, broadly filter out soft-deleted columns.
*
* @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.Request
*/
open val restriction: Op<Boolean>? get() = null

private fun Query.addRestriction(): Query = restriction?.let { filter ->
adjustWhere {
where?.let { wh ->
wh and filter
} ?: filter
}
} ?: this

private fun Op<Boolean>.andRestriction(): Op<Boolean> = restriction?.let { this and it } ?: this

/**
* Returns a [Query] to select all columns in [dependsOnTables] with a WHERE clause that includes
* the provided [op] conditional expression.
*/
open fun searchQuery(op: Op<Boolean>): Query =
dependsOnTables.select(dependsOnColumns).where { op }.setForUpdateStatus()
dependsOnTables.select(dependsOnColumns).where { op.andRestriction() }.setForUpdateStatus()

/**
* Counts the amount of [Entity] instances that conform to the [op] conditional expression.
Expand All @@ -331,6 +351,7 @@ abstract class EntityClass<ID : Comparable<ID>, out T : Entity<ID>>(
val countExpression = table.id.count()
val query = table.select(countExpression).notForUpdate()
op?.let { query.adjustWhere { op } }
query.addRestriction()
return query.first()[countExpression]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.jetbrains.exposed.sql.tests.shared.entities

import kotlinx.datetime.Clock
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.exceptions.EntityNotFoundException
import org.jetbrains.exposed.dao.id.EntityID
Expand All @@ -8,16 +9,21 @@ import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.isNull
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import org.jetbrains.exposed.sql.tests.DatabaseTestsBase
import org.jetbrains.exposed.sql.tests.TestDB
import org.jetbrains.exposed.sql.tests.currentDialectTest
import org.jetbrains.exposed.sql.tests.shared.*
import org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.Notes.nullable
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.inTopLevelTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.vendors.OracleDialect
import org.junit.Assert.assertThrows
import org.junit.Test
import java.sql.Connection
import java.time.Instant
import java.util.*
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
Expand Down Expand Up @@ -1512,14 +1518,23 @@ class EntityTests : DatabaseTestsBase() {

object RequestsTable : IdTable<String>() {
val requestId: Column<String> = varchar("requestId", 256)
val deletedAt = timestamp("deleted_at").nullable()
override val primaryKey = PrimaryKey(requestId)
override val id: Column<EntityID<String>> = requestId.entityId()
}

class Request(id: EntityID<String>) : Entity<String>(id) {
companion object : EntityClass<String, Request>(RequestsTable)
companion object : EntityClass<String, Request>(RequestsTable) {
override val restriction: Op<Boolean> = RequestsTable.deletedAt.isNull()
}

var requestId by RequestsTable.requestId

override fun delete() {
RequestsTable.update({ RequestsTable.id eq id }) {
it[deletedAt] = Clock.System.now()
}
}
}

@Test
Expand All @@ -1534,6 +1549,32 @@ class EntityTests : DatabaseTestsBase() {
}
}

@Test
fun `testRestrictionThroughSoftDeletePattern`() {
withTables(RequestsTable) {
val request = Request.new {
requestId = "123"
}

request.delete()

// gone from the DAO
assertEquals(0, Request.all().count())
assertEquals(0, Request.count())
assertEquals(0, Request.count(RequestsTable.requestId eq "123"))
assertNull(Request.findById(request.id))
assertThrows(EntityNotFoundException::class.java) {
Request[request.id]
}

// but can be seen in the database
val actual = RequestsTable.selectAll().single()
assertEquals(request.id, actual[RequestsTable.id])
assertEquals("123", actual[RequestsTable.requestId])
assertNotNull(actual[RequestsTable.deletedAt])
}
}

object CreditCards : IntIdTable("CreditCards") {
val number = varchar("number", 16)
val spendingLimit = ulong("spendingLimit").databaseGenerated()
Expand Down

0 comments on commit 1e71160

Please sign in to comment.