https://kotlinlang.org logo
#server
Title
# server
h

Hildebrandt Tobias

03/07/2024, 11:54 AM
Hi, I think I am running in circles with the following problem. I already tried sealed classes and function references, but I think I am missing something. Depending on an Identifier, like an enum, I want to provide a function(signature) that forces the implementing person to give all neccessary fields. As an example let's say that I have two `AuditActions`:
CHANGE_PROFILE
and
ADD_PROFILE
. The first needs
id, from, to
and the latter just needs
id, name
. Somewhere up in the code an
Audit(user, auditAction)
is created and passed down. It eventuelle lands at the point where it should be written to the databse. So I have imagine something that looks into the `AuditAction`and depending if it's change or add a different function(signature) is presented, they all return a
Map<AuditDetail, String>
. So in pseudo code something like this:
Copy code
enum class AuditDetail {
    ID,
    NAME,
    FROM,
    TO,
}

object AuditPresets {
    fun profileChange(id: String, from: String, to: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.FROM to from,
        AuditDetail.TO to to,
    )
    fun profileAdd(id: String, name: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.NAME to name,
    )
}

enum class AuditAction(val transformer: ???) {
    PROFILE_CHANGE(AuditPresets::profileChange),
    PROFILE_ADD(AuditPresets::profileAdd)
}
Now this obviously doesn't work since calling
audit.auditAction()
could be any of the
AuditAction
and the compiler doesn't know the signature ahead of time. I just want a way to connect the
AuditAction
enum(or sealed class or whatever) to a specific function even when the result is something like:
Copy code
return when (audit.auditAction) {
    AuditAction.PROFILE_CHANGE -> AuditAction.PROFILE_CHANGE("0","oldValue", "newValue")
    AuditAction.PROFILE_ADD -> AuditAction.PROFILE_ADD("1","newName")
}
I feel like the solution might be simple and obvious and I just can't see the forest for the trees.
I also thought about something like that but obviously that also doesn't work
Copy code
sealed class AuditAction {
    object ProfileAdd : AuditAction()
    data class ProfileAdd(
        val id: String, 
        val name: String,
        val details: Map<AuditDetail, String> = mapOf(
            AuditDetail.ID to id,
            AuditDetail.NAME to name
        )
    ) : AuditAction()
}
Okay I got it:
Copy code
object AuditPresets {
    fun profileAdd(name: String, id: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.NAME to name,
    )
    fun profileChange(id: String, from: String, to: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.FROM to from,
        AuditDetail.TO to to
    )
}
sealed class AuditAction {
    object ProfileAdd : AuditAction() {
        operator fun invoke() = AuditPresets::profileAdd
    }
    object ProfileChange : AuditAction() {
        operator fun invoke() = AuditPresets::profileChange
    }
}
Usage:
Copy code
when (audit.action) {
    AuditAction.ProfileAdd -> AuditAction.ProfileAdd()("0", "1")
    AuditAction.ProfileChange -> AuditAction.ProfileChange()("0", "old", "new")
}
Is there a way to omit the empty
()
?
rubber duck 1
c

CLOVIS

03/12/2024, 10:05 AM
Copy code
sealed class AuditAction {
    object ProfileAdd : AuditAction() {
        val create get() = AuditPresets::profileAdd
    }
    object ProfileChange : AuditAction() {
        val create get() = AuditPresets::profileChange
    }
}
Usage:
Copy code
when (audit.action) {
    AuditAction.ProfileAdd -> AuditAction.ProfileAdd.create("0", "1")
    AuditAction.ProfileChange -> AuditAction.ProfileChange.create("0", "old", "new")
}
I'm not sure why this doesn't work, though:
Copy code
sealed interface AuditDetail {
    
    sealed interface WithId : AuditDetail {
        val id: String
    }

    sealed interface WithName : AuditDetail {
        val name: String
    }  

    // …

    data class ProfileChange(
        override val id: String,
        override val name: String,
        override val from: String,
    ) : AuditDetail, WithId, WithName, WithFrom

    data class ProfileAdd(
        override val id: String,
        override val name: String,
    ) : AuditDetail, WithId, WithName
}
Ah, it's because you want the map as output. Then, just simplifying your idea:
Copy code
sealed class AuditAction {
    object ProfileAdd : AuditAction() {
        operator fun invoke(name: String, id: String) = mapOf(
            AuditDetail.ID to id,
            AuditDetail.NAME to name,
        )
    }
    // …
}
I'm not a fan that the
invoke
operator is abused to construct a data type, it should be used for operations.
Or,
Copy code
abstract class AbstractAuditAction {
    private val data = HashMap<AuditDetail, String>()

    protected fun register(id: AuditDetail, value: String) {
        data[id] = value
    }

    fun asMap() = HashMap(data)

    override fun equals(…) { … }
    override fun hashCode() { … }
    override fun toString() = "${this::class}($data)"
}

sealed class AuditAction {
    class ProfileChange(name: String, id: String) : AuditAction(), AbstractAuditAction() {
        init {
            register(NAME, name)
            register(ID, id)
        }
    }
}
but I'm not sure it's simpler 😅
h

Hildebrandt Tobias

03/15/2024, 12:37 AM
Hey, wow thanks for all the input. I really appreciate it! I had a busy week and wasn't much on here sadly. In the end I reached roughly the same as your first answer and it works really nice. Here a shortened excerpt (because it's already very long):
Copy code
object AuditPresets {
    fun profileAddRemove(id: String, name: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.NAME to name,
    )
    fun profileChange(id: String, from: String, to: String) = mapOf(
        AuditDetail.ID to id,
        AuditDetail.FROM to from,
        <http://AuditDetail.TO|AuditDetail.TO> to to
    )
}
sealed class AuditType(open val action: AuditAction) {
    sealed class Ibis(override val action: AuditAction) : AuditType(action) {
        data object ProfileAdd : Ibis(AuditAction.IBIS_PROFILE_ADD) { val details = AuditPresets::profileAddRemove }
        data object ProfileRemove : Ibis(AuditAction.IBIS_PROFILE_REMOVE) { val details = AuditPresets::profileAddRemove }
        data object ProfileChange : Ibis(AuditAction.IBIS_PROFILE_CHANGE) { val details = AuditPresets::profileChange }
    }
    data object None : AuditType(AuditAction.NONE)
}
enum class AuditAction {
    NONE,
    IBIS_PROFILE_ADD,
    IBIS_PROFILE_REMOVE,
    IBIS_PROFILE_CHANGE,
}
enum class AuditDetail {
    COMPANY,
    SUBJECT,
    PARENT,
    KEY,
    VALUE,
    ID,
    NAME,
    FROM,
    TO
}
data class Audit(
    val user: String,
    val action: AuditAction,
    val timestamp: Instant = Clock.System.now(),
    val details: Map<AuditDetail, String>
)
And here an actual example where I use it:
Copy code
private fun audit(request: Request) {
    val new = message(request)
    val from = settingsRepository.getSettingsByTargetAndType(new.target, new.type).value
    val to = new.value
    auditor.writeAudit(Audit(
        user = security.tokenLens(request).id,
        action = AuditType.Settings.AuditSet.action,
        details = AuditType.Settings.AuditSet.details(new.target, from, to)
    ))
}
The only thing I am not happy with is that currently all
AuditType
are in one file/sealed class. When I have time I'd like to see if I can break that down so that each kind of
AuditType
can be in its own file.
c

CLOVIS

03/15/2024, 8:54 AM
You don't have to put all sealed class implementations in the same file 🙂 they have to be in the same package though.
h

Hildebrandt Tobias

03/15/2024, 12:34 PM
Good to know, thank you!
2 Views