Skip to content

Commit

Permalink
Rework state4k to have enter commands (#57)
Browse files Browse the repository at this point in the history
* upgrade versions
data4k - rename content() to unwrap()
data4k - add ability to set raw child data nodes

* upgrade dependencies
new version of state4k which defines commands that are triggered on entering a state

* Release 2.15.0.0
  • Loading branch information
daviddenton authored Mar 18, 2024
1 parent 34d7c1f commit dce4560
Show file tree
Hide file tree
Showing 17 changed files with 136 additions and 114 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
This list is not intended to be all-encompassing - it will document major and breaking API changes with their rationale
when appropriate:

### v2.15.0.0
- **all** : Upgrade of dependencies and gradle.
- **state4k** : [Breaking change] Migrated to new construction mechanic. We now define sent commands with `onEnter(commands)` and these are defined on the state itself. This allows for a more consistent way of defining state transitions.

### v2.14.1.0
- **result4k** : Add `resultFromCatching` to catch some (but not all!) exceptions and turn them into a `Result4k` H/T @MarcusDunn

Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
7 changes: 4 additions & 3 deletions state4k/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ val simpleStateMachine = StateMachine<SimpleState, SimpleEntity, SimpleEvent, Si
lens,
// define the state transitions for state one
StateBuilder<SimpleState, SimpleEntity, SimpleCommand>(one)
.transition<SimpleEvent1>(two, { e, o -> o.copy(lastAction = "received $e") }, aCommand),
.transition<SimpleEvent1>(two) { e, o -> o.copy(lastAction = "received $e") },

// define the state transitions for state two
StateBuilder<SimpleState, SimpleEntity, SimpleCommand>(two)
.transition<SimpleEvent2>(three, { e, o -> o.copy(lastAction = "received $e") })
.transition<SimpleEvent3>(four, { e, o -> o.copy(lastAction = "received $e") })
.onEnter(aCommand)
.transition<SimpleEvent2>(three) { e, o -> o.copy(lastAction = "received $e") }
.transition<SimpleEvent3>(four) { e, o -> o.copy(lastAction = "received $e") }
)
```

Expand Down
22 changes: 0 additions & 22 deletions state4k/src/main/kotlin/dev/forkhandles/state4k/EntityStateLens.kt

This file was deleted.

17 changes: 10 additions & 7 deletions state4k/src/main/kotlin/dev/forkhandles/state4k/StateBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ package dev.forkhandles.state4k
/**
* Builder for the mechanics of how to transition out of a particular state
*/
class StateBuilder<State, Entity, Command>(
val start: State,
val transitions: List<StateTransition<State, Entity, *, Command>> = emptyList()
class StateBuilder<StateId, Entity, Command>(
val start: StateId,
val onEnter: Command? = null,
val transitions: List<StateTransition<StateId, Entity, *>> = emptyList()
) {
fun onEnter(nextCommand: Command) = StateBuilder(start, nextCommand, transitions)

/**
* Define a state and the transitions out of that state via events, with an optional command to send next
*/
inline fun <reified Event : Any> transition(
end: State,
noinline applyTo: (Event, Entity) -> Entity = { _, entity -> entity },
nextCommand: Command? = null
end: StateId,
noinline applyTo: (Event, Entity) -> Entity = { _, entity -> entity }
) = StateBuilder(
start,
transitions + StateTransition(start, Event::class, end, applyTo, nextCommand)
onEnter,
transitions + StateTransition(Event::class, end, applyTo)
)
}
22 changes: 22 additions & 0 deletions state4k/src/main/kotlin/dev/forkhandles/state4k/StateIdLens.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dev.forkhandles.state4k

/**
* Responsible for retrieving and setting the state on an entity
*/
interface StateIdLens<Entity, StateId> {
operator fun invoke(entity: Entity): StateId
operator fun invoke(entity: Entity, newState: StateId): Entity

companion object {
/**
* Convenience function for creating an EntityStateLens
*/
operator fun <Entity, StateId> invoke(
get: (Entity) -> StateId,
set: (Entity, StateId) -> Entity,
) = object : StateIdLens<Entity, StateId> {
override fun invoke(entity: Entity) = get(entity)
override fun invoke(entity: Entity, newState: StateId) = set(entity, newState)
}
}
}
46 changes: 32 additions & 14 deletions state4k/src/main/kotlin/dev/forkhandles/state4k/StateMachine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,27 @@ import dev.forkhandles.state4k.StateTransitionResult.OK
* Standard state machine pattern. Build with a list of state definitions and transition over them with
* events. Each transition can create a new command to be issued.
*/
class StateMachine<State, Entity, Event : Any, Command, Error>(
class StateMachine<StateId, Entity, Event : Any, Command, Error>(
private val commands: Commands<Entity, Command, Error>,
private val stateLens: EntityStateLens<Entity, State>,
private val stateTransitions: List<StateTransition<State, Entity, *, Command>> = emptyList()
private val stateLens: StateIdLens<Entity, StateId>,
private val states: List<State<StateId, Entity, Command>>,
) {
constructor(
commands: Commands<Entity, Command, Error>,
entityStateLens: EntityStateLens<Entity, State>,
vararg stateBuilders: StateBuilder<State, Entity, Command>
) : this(commands, entityStateLens, stateBuilders.flatMap { state -> state.transitions })
stateIdLens: StateIdLens<Entity, StateId>,
vararg stateBuilders: StateBuilder<StateId, Entity, Command>
) : this(commands,
stateIdLens,
stateBuilders.map { State(it.start, it.onEnter, it.transitions) }
)

/**
* Transition the entity by checking then running a command, and applying the resultant event to the entity
*/
fun <Next : Event> transition(entity: Entity, command: Command, toEvent: (Entity) -> Result<Next, Error>)
: Result<StateTransitionResult<State, Entity, Command>, Error> =
: Result<StateTransitionResult<StateId, Entity, Command>, Error> =
when {
stateTransitions.any { it.end == stateLens(entity) && it.nextCommand == command } ->
states.any { it.id == stateLens(entity) && it.onEnter == command } ->
toEvent(entity).flatMap { transition(entity, it) }

else -> Success(IllegalCommand(entity, command))
Expand All @@ -40,16 +43,31 @@ class StateMachine<State, Entity, Event : Any, Command, Error>(
*/
@Suppress("UNCHECKED_CAST")
fun <Next : Event> transition(entity: Entity, event: Next)
: Result<StateTransitionResult<State, Entity, Command>, Error> =
stateTransitions.firstOrNull { stateLens(entity) == it.start && event::class == it.event }
?.let { transition ->
transition as? StateTransition<State, Entity, Next, Command> ?: error("Illegal state transition")
(transition.nextCommand?.let { commands(entity, it) } ?: Success(Unit))
: Result<StateTransitionResult<StateId, Entity, Command>, Error> {
val transition = states
.firstOrNull { it.id == stateLens(entity) }
?.let { state ->
state.transitions.firstOrNull { event::class == it.event }
?.let { it as? StateTransition<StateId, Entity, Next> ?: error("Illegal state transition") }
}

return transition
?.let {
(states.firstOrNull { state -> state.id == it.end }
?.onEnter?.let { commands(entity, it) } ?: Success(Unit))
.also { println(it) }
.map { OK(stateLens(transition.applyTo(event, entity), transition.end)) }
} ?: Success(IllegalEvent(entity, event))
}

/**
* Render the state machine using the passed renderer
*/
fun renderUsing(renderer: StateMachineRenderer) = renderer(stateTransitions)
fun renderUsing(renderer: StateMachineRenderer) = renderer(states)
}

data class State<StateId, Entity, Command>(
val id: StateId,
val onEnter: Command?,
val transitions: List<StateTransition<StateId, Entity, *>>
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package dev.forkhandles.state4k

fun interface StateMachineRenderer {
operator fun invoke(transitions: List<StateTransition<*, *, *, *>>): String
operator fun invoke(states: List<State<*, *, *>>): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package dev.forkhandles.state4k

import kotlin.reflect.KClass

data class StateTransition<State, Entity, Event : Any, Command>(
val start: State,
data class StateTransition<StateId, Entity, Event : Any>(
val event: KClass<Event>,
val end: State,
val applyTo: (Event, Entity) -> Entity,
val nextCommand: Command?
val end: StateId,
val applyTo: (Event, Entity) -> Entity
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,30 @@ package dev.forkhandles.state4k
/**
* The various outcomes which can happen out of a state machine transition
*/
sealed interface StateTransitionResult<State, Entity, CommandType> {
sealed interface StateTransitionResult<StateId, Entity, CommandType> {
val entity: Entity

/**
* Apply a transformation to update the entity inside the result. Useful for
* manipulating the result.
*/
fun map(fn: (Entity) -> Entity): StateTransitionResult<State, Entity, CommandType>
fun map(fn: (Entity) -> Entity): StateTransitionResult<StateId, Entity, CommandType>

/**
* This transition was valid and the updated entity is enclosed in the field
*/
data class OK<State, Entity, CommandType>(override val entity: Entity) :
StateTransitionResult<State, Entity, CommandType> {
data class OK<EntityState, Entity, CommandType>(override val entity: Entity) :
StateTransitionResult<EntityState, Entity, CommandType> {
override fun map(fn: (Entity) -> Entity) = copy(entity = fn(entity))
}

/**
* This transition via an event was not valid. The unmodified entity is enclosed in the field.
*/
data class IllegalEvent<State, Entity, CommandType>(
data class IllegalEvent<EntityState, Entity, CommandType>(
override val entity: Entity,
val event: Any
) : StateTransitionResult<State, Entity, CommandType> {
) : StateTransitionResult<EntityState, Entity, CommandType> {
override fun map(fn: (Entity) -> Entity) = copy(entity = fn(entity))
}

Expand Down
29 changes: 14 additions & 15 deletions state4k/src/main/kotlin/dev/forkhandles/state4k/render/Puml.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,19 @@ import dev.forkhandles.state4k.StateMachineRenderer
/**
* Standard PUML diagram generator for a StateMachine
*/
fun Puml(title: String, commandColour: String = "PaleGoldenRod") = StateMachineRenderer { transitions ->
val commands = transitions
.groupBy { it.end.toString() }
.mapValues { it.value.mapNotNull { it.nextCommand?.toString() } }
.map {
val stateName = it.key
"""
|state $stateName {
fun Puml(title: String, commandColour: String = "PaleGoldenRod") = StateMachineRenderer { states ->

val stateDefinitions = states.joinToString("\n") {
val stateId = it.id
"""
|state $stateId {
|${
it.value.sortedBy { it }
.joinToString("\n") { command -> " state \"$command\" as ${stateName}_$command <<Command>>" }
}
it.onEnter?.let { " state \"$it\" as ${stateId}_$it <<Command>>" } ?: ""
}
|}
""".trimMargin()
}.joinToString("\n")
}


"""
|@startuml
Expand All @@ -28,12 +26,13 @@ fun Puml(title: String, commandColour: String = "PaleGoldenRod") = StateMachineR
| BorderColor<<Command>> $commandColour
|}
|
|$commands
|$stateDefinitions
|
|title $title
|${
transitions
.joinToString("\n") { " ${it.start} --> ${it.end} : ${it.event.simpleName}" }
states.joinToString("\n") { state ->
state.transitions.joinToString("\n") { " ${state.id} --> ${it.end} : ${it.event.simpleName}" }
}
}
|@enduml""".trimMargin()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import dev.forkhandles.result4k.Result4k
import dev.forkhandles.result4k.Success
import dev.forkhandles.result4k.failureOrNull
import dev.forkhandles.result4k.valueOrNull
import dev.forkhandles.state4k.EntityStateLens
import dev.forkhandles.state4k.StateIdLens
import dev.forkhandles.state4k.StateMachine
import dev.forkhandles.state4k.StateTransitionResult
import dev.forkhandles.state4k.StateTransitionResult.IllegalCommand
Expand Down Expand Up @@ -81,15 +81,18 @@ class StateMachineTest {
(valueOrNull()!! as OK).entity

@Test
fun `failure during sending of next event`() {
fun `failure during sending of next command`() {
val stateMachine = StateMachine<MyState, MyEntity, MyEvent, MyCommandType, String>(
{ _, _ -> Failure("foo") },
EntityStateLens(MyEntity::state, MyEntity::withState),
StateIdLens(MyEntity::state, MyEntity::withState),
buildState(one)
.transition<OneToTwoEvent>(two, { e, o -> o.copy(data = e.data) }, firedOnTwo)
.transition<OneToTwoEvent>(two) { e, o -> o.copy(data = e.data) },
buildState(two)
.onEnter(firedOnTwo)
)

expectThat(stateMachine.transition(MyEntity(one, 0.0), OneToTwoEvent).failureOrNull())
val transition = stateMachine.transition(MyEntity(one, 0.0), OneToTwoEvent)
expectThat(transition.failureOrNull())
.isEqualTo(("foo"))
}

Expand Down
28 changes: 13 additions & 15 deletions state4k/src/test/kotlin/dev/forkhandles/state4k/example.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,23 @@ import dev.forkhandles.result4k.Success

val exampleStateMachine = StateMachine<MyState, MyEntity, MyEvent, MyCommandType, String>(
{ _, _ -> Success(Unit) },
EntityStateLens(MyEntity::state) { entity, state -> entity.copy(state = state) },
StateIdLens(MyEntity::state) { entity, state -> entity.copy(state = state) },
buildState(one)
.transition<OneToTwoEvent>(two, { _, o -> o.copy(data = OneToTwoEvent.data) }, firedOnTwo)
.transition<OneToFourEvent>(four, { _, o -> o.copy(data = OneToFourEvent.data) })
.transition<OneToSixEvent>(six, { _, o -> o.copy(data = OneToSixEvent.data) }, eject),
.transition<OneToTwoEvent>(two) { _, o -> o.copy(data = OneToTwoEvent.data) }
.transition<OneToFourEvent>(four) { _, o -> o.copy(data = OneToFourEvent.data) }
.transition<OneToSixEvent>(six) { _, o -> o.copy(data = OneToSixEvent.data) },
buildState(two)
.transition<TwoToThreeEvent>(
three,
{ _, o -> o.copy(data = TwoToThreeEvent.data) },
firedOnThree
),
.onEnter(firedOnTwo)
.transition<TwoToThreeEvent>(three) { _, o -> o.copy(data = TwoToThreeEvent.data) },
buildState(three)
.transition<ThreeToFourEvent>(
four,
{ _, o -> o.copy(data = ThreeToFourEvent.data) },
eject
),
.onEnter(firedOnThree)
.transition<ThreeToFourEvent>(four) { _, o -> o.copy(data = ThreeToFourEvent.data) },
buildState(four)
.transition<ThreeToFourEvent>(five, { _, o -> o.copy(data = ThreeToFourEvent.data) })
.onEnter(eject)
.transition<ThreeToFourEvent>(five) { _, o -> o.copy(data = ThreeToFourEvent.data) },
buildState(five),
buildState(six)
.onEnter(eject)
)

fun buildState(start: MyState) = StateBuilder<MyState, MyEntity, MyCommandType>(start)
Loading

0 comments on commit dce4560

Please sign in to comment.