diff --git a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt index 8c349f6..5a8a172 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt @@ -64,10 +64,20 @@ fun state(block: suspend StateBuilder.() -> Unit) = flow(block) /** * Exception used internally by the [state] function to provide cancellation functionality. */ -class StateBuilderCancellation : RuntimeException("The 'state' builder has been cancelled") +private class StateBuilderCancellation : RuntimeException("The 'state' builder has been cancelled") //region Predicate checkers +/** + * Stops the currently running [state] builder. + * + * If no [state] builder is active, acts as a [CancellationException]. + */ +@Suppress("UnusedReceiverParameter") // used for namespacing +fun StateBuilder.cancel(): Nothing { + throw StateBuilderCancellation() +} + /** * Ensures that [condition] is `true`. * @@ -85,7 +95,7 @@ suspend inline fun StateBuilder.ensureValid( if (!condition) { emit(failed(Status.StandardFailure.Kind.Invalid, lazyMessage(), progression = Progression.done())) - throw StateBuilderCancellation() + cancel() } } @@ -106,7 +116,7 @@ suspend inline fun StateBuilder.ensureAuthenticated( if (!condition) { emit(failed(Status.StandardFailure.Kind.Unauthenticated, lazyMessage(), progression = Progression.done())) - throw StateBuilderCancellation() + cancel() } } @@ -127,7 +137,7 @@ suspend inline fun StateBuilder.ensureAuthorized( if (!condition) { emit(failed(Status.StandardFailure.Kind.Unauthorized, lazyMessage(), progression = Progression.done())) - throw StateBuilderCancellation() + cancel() } } @@ -148,7 +158,7 @@ suspend inline fun StateBuilder.ensureFound( if (!condition) { emit(failed(Status.StandardFailure.Kind.NotFound, lazyMessage(), progression = Progression.done())) - throw StateBuilderCancellation() + cancel() } } -- 2.51.2 From 32a838437e4ec7c1815952436e6b10be2a84acb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 19 Oct 2022 10:57:04 +0200 Subject: [PATCH 2/3] feat(state): *Success variants of the standard flow operators --- .../kotlin/opensavvy.state/Slice.kt | 21 +++++ .../kotlin/opensavvy.state/State.kt | 42 ++++++++- .../opensavvy.state/ComprehensionsTest.kt | 94 +++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt diff --git a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt b/state/src/commonMain/kotlin/opensavvy.state/Slice.kt index b5d9a11..3a67a8a 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/Slice.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/Slice.kt @@ -118,3 +118,24 @@ data class Slice( } } + +//region Error management + +/** + * Maps a successful slice from [I] to [O] using [transform]. + * + * If the slice is not successful, it is kept unchanged. + */ +inline fun Slice.mapSuccess(transform: (I) -> O): Slice { + val (status, progression) = this + + val newStatus: Status = when (status) { + is Status.Failed -> status + is Status.Pending -> status + is Status.Successful -> Status.Successful(transform(status.value)) + } + + return Slice(newStatus, progression) +} + +//endregion diff --git a/state/src/commonMain/kotlin/opensavvy.state/State.kt b/state/src/commonMain/kotlin/opensavvy.state/State.kt index 691a629..293946d 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/State.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/State.kt @@ -1,8 +1,6 @@ package opensavvy.state -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.* import opensavvy.state.Slice.Companion.valueOrNull import opensavvy.state.Slice.Companion.valueOrThrow @@ -17,6 +15,8 @@ import opensavvy.state.Slice.Companion.valueOrThrow */ typealias State = Flow> +//region Event selection + /** * Skips the loading events in [State]. */ @@ -45,3 +45,39 @@ suspend fun State.firstResultOrNull() = firstResult().valueOrNull * Only use this method in contexts where being notified on new values is not important. */ suspend fun State.firstResultOrThrow() = firstResult().valueOrThrow + +//endregion +//region Error management + +inline fun State.onEachSuccess(crossinline block: (T) -> Unit): State = onEach { slice -> + val (status, _) = slice + + if (status is Status.Successful) + block(status.value) +} + +inline fun State.mapSuccess(crossinline transform: (I) -> O): State = map { slice -> + slice.mapSuccess(transform) +} + +inline fun State.mapSuccessSlice(crossinline transform: (I) -> Slice): State = map { + val (status, progression) = it + + when (status) { + is Status.Failed -> Slice(status, progression) + is Status.Pending -> Slice(status, progression) + is Status.Successful -> transform(status.value) + } +} + +fun State.flatMapSuccess(transform: suspend StateBuilder.(I) -> Unit): State = transform { + val (status, progression) = it + + when (status) { + is Status.Failed -> emit(Slice(status, progression)) + is Status.Pending -> emit(Slice(status, progression)) + is Status.Successful -> emitAll(state { transform(status.value) }) + } +} + +//endregion diff --git a/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt b/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt new file mode 100644 index 0000000..a04c04d --- /dev/null +++ b/state/src/commonTest/kotlin/opensavvy.state/ComprehensionsTest.kt @@ -0,0 +1,94 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package opensavvy.state + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import opensavvy.state.Slice.Companion.failed +import opensavvy.state.Slice.Companion.pending +import opensavvy.state.Slice.Companion.successful +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * This is a series of examples of how to combine state instances together. + */ +class ComprehensionsTest { + + private fun decodeStringOrFailed(str: String): Slice { + val decoded = str.toIntOrNull() + ?: return failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: '$str'") + return successful(decoded) + } + + private fun decodeString(str: String) = state { + emit(pending(0.0)) + delay(10) + emit(decodeStringOrFailed(str)) + } + + private fun strings() = state { + emit(successful("5", Progression.loading(0.0))) + delay(100) + emit(successful("10", Progression.loading(0.5))) + delay(100) + emit(successful("wtf", Progression.done())) + } + + @Test + fun mapValue() = runTest { + val results = strings() + .mapSuccess { it.toIntOrNull() } + .toList() + + val expected = listOf( + successful(5, Progression.loading(0.0)), + successful(10, Progression.loading(0.5)), + successful(null, Progression.done()) + ) + + assertEquals(expected, results) + } + + /** + * For each string instance, decode it into an integer if possible. + */ + @Test + fun mapSlice() = runTest { + val results = strings() + .mapSuccessSlice { decodeStringOrFailed(it) } + .toList() + + val expected = listOf( + successful(5), + successful(10), + failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: 'wtf'"), + ) + + assertEquals(expected, results) + } + + @Test + fun oneToMany() = runTest { + val results = strings() + .flatMapSuccess { + emitAll(decodeString(it)) + } + .toList() + + val expected = listOf( + pending(0.0), + successful(5), + pending(0.0), + successful(10), + pending(0.0), + failed(Status.StandardFailure.Kind.Invalid, "The passed string is not an integer: 'wtf'"), + ) + + assertEquals(expected, results) + } + +} -- 2.51.2 From ad2f90b4ac0b5911813f896da08003c25de6d23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 19 Oct 2022 11:12:40 +0200 Subject: [PATCH 3/3] feat(state): The state builder catches IllegalArgumentException (from 'require') and IllegalStateException (from 'check' and 'error') --- .../kotlin/opensavvy.state/StateBuilder.kt | 22 +++++++++++-- .../opensavvy.state/StateBuilderTest.kt | 32 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt index 5a8a172..fd398fb 100644 --- a/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt +++ b/state/src/commonMain/kotlin/opensavvy.state/StateBuilder.kt @@ -43,17 +43,35 @@ fun state(block: suspend StateBuilder.() -> Unit) = flow(block) is Status.StandardFailure -> emit( failed( it.kind, - it.message ?: "Caught a downstream error", + it.message ?: "Caught an error without message", it.cause, Progression.done() ) ) + is IllegalArgumentException -> emit( + failed( + Status.StandardFailure.Kind.Invalid, + it.message ?: "Caught an IllegalArgumentException without message", + cause = it, + Progression.done(), + ) + ) + + is IllegalStateException -> emit( + failed( + Status.StandardFailure.Kind.Invalid, + it.message ?: "Caught an IllegalStateException without message", + cause = it, + Progression.done(), + ) + ) + // All other exceptions are caught into the Kind.Unknown standard failure. else -> emit( failed( Status.StandardFailure.Kind.Unknown, - "Unknown error caught in the state builder", + it.message ?: "Unknown error caught in the state builder", it, Progression.done() ) diff --git a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt index 2ef243d..e6b1af9 100644 --- a/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt +++ b/state/src/commonTest/kotlin/opensavvy.state/StateBuilderTest.kt @@ -47,13 +47,41 @@ class StateBuilderTest { @Test fun throwOther() = runTest { val data = state { - error("some error") + throw RuntimeException("some error") } assertEquals( failed( Status.StandardFailure.Kind.Unknown, - "Unknown error caught in the state builder" + "some error" + ), data.firstResult() + ) + } + + @Test + fun throwIllegalArgumentException() = runTest { + val data = state { + require(false) { "some error" } + } + + assertEquals( + failed( + Status.StandardFailure.Kind.Invalid, + "some error" + ), data.firstResult() + ) + } + + @Test + fun throwIllegalStateException() = runTest { + val data = state { + check(false) { "some error" } + } + + assertEquals( + failed( + Status.StandardFailure.Kind.Invalid, + "some error" ), data.firstResult() ) }