From 63ba1957f813df006d85a658642d2ed16e7a5cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 18 Oct 2022 18:31:38 +0200 Subject: [PATCH 1/3] feat(state): Expose a canonical method to cancel the flow builder The StateBuilderCancellation exception is not part of the API, and may be removed in the future. The 'cancel' function guarantees downstream code will continue to work. --- .../kotlin/opensavvy.state/StateBuilder.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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() ) } -- 2.51.2