I’m trying to implement an audit feature similar t...
# exposed
d
I’m trying to implement an audit feature similar to Spring Data JPA (e.g., @CreatedDate, @LastModifiedDate). Here’s what I’ve tried so far: 1. Using EventHook – the updatedAt field is set after the SQL UPDATE, which can potentially cause infinite loops → not suitable. 2. Developed a custom interceptor inspired by EntityLifecycleInterceptor – but it’s unreliable since SPI order is not guaranteed. 3. Using property delegates – works, but it’s cumbersome since it must be applied to every auditable property manually. What I ideally want is something like this:
Copy code
interface AuditableEntity {
    val createdAt: Instant
    val updatedAt: Instant
}
And then assign values to createdAt and updatedAt automatically via an AuditableEntityLifecycleInterceptor, just before INSERT or UPDATE. Is this kind of approach feasible in Exposed 0.60.0?
I got solution by override Entity.flush() like this
Copy code
interface Auditable {
    val createdBy: String?
    val createdAt: Instant?
    val updatedBy: String?
    val updatedAt: Instant?
}

object UserContext {
    const val DEFAULT_USERNAME = "system"
    val CURRENT_USER: ScopedValue<String?> = ScopedValue.newInstance()

    fun <T> withUser(username: String, block: () -> T): T {
        return ScopedValue.where(CURRENT_USER, username).call(block)
    }

    fun getCurrentUser(): String =
        runCatching { CURRENT_USER.get() }.getOrNull() ?: DEFAULT_USERNAME
}

abstract class AuditableIdTable<ID: Any>(name: String = ""): IdTable<ID>(name) {

    val createdBy = varchar("created_by", 50).clientDefault { UserContext.getCurrentUser() }.nullable()
    val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp).nullable()

    val updatedBy = varchar("updated_by", 50).nullable()
    val updatedAt = timestamp("updatedAt_at").nullable()

}

abstract class AuditableEntity<ID: Any>(id: EntityID<ID>): Entity<ID>(id), Auditable {

    companion object: KLogging()

    override var createdBy: String? = null
    override var createdAt: Instant? = null
    override var updatedBy: String? = null
    override var updatedAt: Instant? = null

    // 엔티티가 업데이트될 때 실행되는 메서드 (flush 호출 전에 호출됨)
    override fun flush(batch: EntityBatchUpdate?): Boolean {
        // 엔티티에 변경된 필드가 있는 경우
        // isNewEntity() 가 internal 이라 사용할 수 없음
        if (writeValues.isNotEmpty() && createdAt != null) {
            log.debug { "entity is updated, setting updatedAt and updatedBy" }
            // 업데이트 시간을 현재로 설정
            updatedAt = Instant.now()
            updatedBy = UserContext.getCurrentUser()
        }
        if (createdAt == null) {
            // 생성 시간이 null 인 경우, 생성 시간과 생성자를 설정
            log.debug { "entity is created, setting createdAt and createdBy" }
            createdAt = Instant.now()
            createdBy = UserContext.getCurrentUser()
        }
        return super.flush(batch)
    }
}


abstract class AuditableIntIdTable(name: String = ""): AuditableIdTable<Int>(name) {
    final override val id = integer("id").autoIncrement().entityId()
    override val primaryKey: PrimaryKey = PrimaryKey(id)
}

abstract class AuditableLongIdTable(name: String = ""): AuditableIdTable<Long>(name) {
    final override val id = long("id").autoIncrement().entityId()
    override val primaryKey: PrimaryKey = PrimaryKey(id)
}

abstract class AuditableUUIDIdTable(name: String = ""): AuditableIdTable<UUID>(name) {
    final override val id = uuid("id").clientDefault { UUID.randomUUID() }.entityId()
    override val primaryKey: PrimaryKey = PrimaryKey(id)
}

abstract class AuditableIntEntity(id: EntityID<Int>): AuditableEntity<Int>(id)
abstract class AuditableLongEntity(id: EntityID<Long>): AuditableEntity<Long>(id)
abstract class AuditableUUIDEntity(id: EntityID<UUID>): AuditableEntity<UUID>(id)
👍 1
Test code
Copy code
object TaskTable: AuditableIntIdTable("tasks") {
    val title = varchar("title", 200)
    val description = text("description")
    val status = varchar("status", 20).default("NEW")
}
class TaskEntity(id: EntityID<Int>): AuditableIntEntity(id) {
    companion object: EntityClass<Int, TaskEntity>(TaskTable)
    var title by TaskTable.title
    var description by TaskTable.description
    var status by TaskTable.status
    override var createdBy by TaskTable.createdBy
    override var createdAt by TaskTable.createdAt
    override var updatedBy by TaskTable.updatedBy
    override var updatedAt by TaskTable.updatedAt
}
@ParameterizedTest
@MethodSource(ENABLE_DIALECTS_METHOD)
fun `auditable entity 생성 시 createAt, updateAt 이 설정된다`(testDB: TestDB) {
    withTables(testDB, TaskTable) {
        UserContext.withUser("test") {
            val now = java.time.Instant.now()
            val task = TaskEntity.new {
                title = "Test Task"
                description = "This is a test task."
                status = "NEW"
            }
            entityCache.clear()
            val loaded = TaskEntity.findById(task.id)!!
            loaded.createdAt.shouldNotBeNull() shouldBeGreaterOrEqualTo now
            loaded.createdBy.shouldNotBeNull() shouldBeEqualTo UserContext.getCurrentUser() // "test"
            loaded.updatedAt.shouldBeNull()
            loaded.updatedBy.shouldBeNull()
            loaded.title = "Test Task - Updated"
            entityCache.clear()
            val updated = TaskEntity.findById(task.id)!!
            updated.createdAt.shouldNotBeNull() shouldBeGreaterOrEqualTo now
            updated.createdBy.shouldNotBeNull() shouldBeEqualTo UserContext.getCurrentUser()
            updated.updatedAt.shouldNotBeNull() shouldBeGreaterOrEqualTo now
            updated.updatedBy.shouldNotBeNull() shouldBeEqualTo UserContext.getCurrentUser()
        }
    }
}