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 c90af22142..c0b8c79900 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 @@ -273,7 +273,7 @@ abstract class EntityClass, out T : Entity>( * * @sample org.jetbrains.exposed.sql.tests.shared.entities.EntityTests.testNonEntityIdReference */ - open fun all(): SizedIterable = wrapRows(table.selectAll().notForUpdate()) + open fun all(): SizedIterable = wrapRows(table.selectAll().addRestriction().notForUpdate()) /** * Gets all the [Entity] instances that conform to the [op] conditional expression. @@ -313,12 +313,32 @@ abstract class EntityClass, out T : Entity>( /** The columns that this [EntityClass] depends on when maintaining relations with managed [Entity] instances. */ open val dependsOnColumns: List> get() = dependsOnTables.columns + /** + * Base SQL predicate automatically applied to all fetch queries. If implemented, it will + * behave as an automatic `AND ` 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? get() = null + + private fun Query.addRestriction(): Query = restriction?.let { filter -> + adjustWhere { + where?.let { wh -> + wh and filter + } ?: filter + } + } ?: this + + private fun Op.andRestriction(): Op = 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): 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. @@ -331,6 +351,7 @@ abstract class EntityClass, out T : Entity>( val countExpression = table.id.count() val query = table.select(countExpression).notForUpdate() op?.let { query.adjustWhere { op } } + query.addRestriction() return query.first()[countExpression] } 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 9dd1ac99d0..d2ef8615e7 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 @@ -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 @@ -8,6 +9,8 @@ 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 @@ -16,6 +19,7 @@ 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.util.* @@ -1512,14 +1516,23 @@ class EntityTests : DatabaseTestsBase() { object RequestsTable : IdTable() { val requestId: Column = varchar("requestId", 256) + val deletedAt = timestamp("deleted_at").nullable() override val primaryKey = PrimaryKey(requestId) override val id: Column> = requestId.entityId() } class Request(id: EntityID) : Entity(id) { - companion object : EntityClass(RequestsTable) + companion object : EntityClass(RequestsTable) { + override val restriction: Op = RequestsTable.deletedAt.isNull() + } var requestId by RequestsTable.requestId + + override fun delete() { + RequestsTable.update({ RequestsTable.id eq id }) { + it[deletedAt] = Clock.System.now() + } + } } @Test @@ -1534,6 +1547,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()