From e24a8c94538ebbef7ac162eafa84e8862bea75d3 Mon Sep 17 00:00:00 2001 From: Chris Myers Date: Wed, 11 Sep 2024 10:48:02 +1000 Subject: [PATCH] Adds specialised outcomeOf Raise DSL to allow for greater interoperability with Results and Eithers (#107) --- lib/api/lib.api | 31 +++++ .../app/cash/quiver/raise/OutcomeBuilder.kt | 95 +++++++++++++++ .../app/cash/quiver/OutcomeOfRaiseTest.kt | 108 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 lib/src/test/kotlin/app/cash/quiver/OutcomeOfRaiseTest.kt diff --git a/lib/api/lib.api b/lib/api/lib.api index a54cecf..7ef42f6 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -324,6 +324,37 @@ public final class app/cash/quiver/extensions/ValidatedKt { public final class app/cash/quiver/raise/OutcomeBuilderKt { public static final fun outcome (Lkotlin/jvm/functions/Function1;)Lapp/cash/quiver/Outcome; + public static final fun outcomeOf (Lkotlin/jvm/functions/Function1;)Lapp/cash/quiver/Outcome; +} + +public final class app/cash/quiver/raise/OutcomeOfRaise : arrow/core/raise/Raise { + public fun (Larrow/core/raise/Raise;)V + public fun attempt (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun bind (Lapp/cash/quiver/Outcome;)Ljava/lang/Object; + public fun bind (Larrow/core/Either;)Ljava/lang/Object; + public final fun bind (Larrow/core/Option;)Ljava/lang/Object; + public fun bind (Larrow/core/Validated;)Ljava/lang/Object; + public fun bind (Larrow/core/continuations/EagerEffect;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun bind (Larrow/core/continuations/Effect;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun bind (Ljava/lang/Object;)Ljava/lang/Object; + public fun bind (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public fun bind (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun bindAll (Larrow/core/NonEmptyList;)Larrow/core/NonEmptyList; + public fun bindAll (Ljava/lang/Iterable;)Ljava/util/List; + public fun bindAll (Ljava/util/Map;)Ljava/util/Map; + public fun bindAll-1TN0_VU (Ljava/util/Set;)Ljava/util/Set; + public final fun bindNull (Ljava/lang/Object;)Ljava/lang/Object; + public final fun bindOption (Larrow/core/Either;)Ljava/lang/Object; + public final fun bindResult (Ljava/lang/Object;)Ljava/lang/Object; + public fun catch (Larrow/core/continuations/Effect;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun ensure (Z)V + public final fun ensureNotNull (Ljava/lang/Object;)Ljava/lang/Object; + public fun invoke (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public fun invoke (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun raise (Ljava/lang/Object;)Ljava/lang/Void; + public fun raise (Ljava/lang/Throwable;)Ljava/lang/Void; + public synthetic fun shift (Ljava/lang/Object;)Ljava/lang/Object; + public fun shift (Ljava/lang/Throwable;)Ljava/lang/Object; } public final class app/cash/quiver/raise/OutcomeRaise : arrow/core/raise/Raise { diff --git a/lib/src/main/kotlin/app/cash/quiver/raise/OutcomeBuilder.kt b/lib/src/main/kotlin/app/cash/quiver/raise/OutcomeBuilder.kt index a5340f3..3ef7920 100644 --- a/lib/src/main/kotlin/app/cash/quiver/raise/OutcomeBuilder.kt +++ b/lib/src/main/kotlin/app/cash/quiver/raise/OutcomeBuilder.kt @@ -1,10 +1,15 @@ package app.cash.quiver.raise import app.cash.quiver.Absent +import app.cash.quiver.Absent.inner import app.cash.quiver.Outcome import app.cash.quiver.Present +import app.cash.quiver.extensions.ErrorOr +import app.cash.quiver.extensions.OutcomeOf +import app.cash.quiver.extensions.toOutcomeOf import app.cash.quiver.failure import app.cash.quiver.present +import app.cash.quiver.toOutcome import arrow.core.Option import arrow.core.Some import arrow.core.getOrElse @@ -74,3 +79,93 @@ class OutcomeRaise(private val raise: Raise) : Raise { return inner.bind().bind() } } + +/** + * DSL build on top of Arrow's Raise for [OutcomeOf]. + * + * Uses `Raise` to provide a slightly optimised builder + * than nesting `either { option { } }` and not-being able to use `@JvmInline value class`. + * + * With context receivers this can be eliminated all together, + * and `context(Raise, Raise)` or `context(Raise, Raise)` can be used instead. + * + * This is a specialised version and allows interoperability with `Result` as the error side is locked down to + * `Throwable`. + */ +@OptIn(ExperimentalTypeInference::class) +inline fun outcomeOf(@BuilderInference block: OutcomeOfRaise.() -> A): OutcomeOf = + fold( + block = { block(OutcomeOfRaise(this)) }, + recover = { eOrAbsent -> + @Suppress("UNCHECKED_CAST") + if (eOrAbsent === Absent) Absent else (eOrAbsent as Throwable).failure() + }, + transform = { it.present() } + ) + +/** + * Emulation of _context receivers_, + * when they're released this can be replaced by _context receiver_ based code in Arrow itself. + * + * We guarantee that the wrapped `Any?` will only result in `Throwable` or `Absent`. + * Exposing this as `Raise` gives natural interoperability with `Raise` DSLs (`Either`). + */ +@OptIn(ExperimentalContracts::class) +class OutcomeOfRaise(private val raise: Raise) : Raise { + + @RaiseDSL + override fun raise(r: Throwable): Nothing = raise.raise(r) + + @RaiseDSL + fun Option.bind(): A { + contract { returns() implies (this@bind is Some) } + return getOrElse { raise.raise(Absent) } + } + + /** + * Ensures a nullable value is not null. Will raise Absent on null. + */ + @RaiseDSL + fun A?.bindNull(): A = ensureNotNull(this) + + /** + * Converts `Result>` to OutcomeOf and binds over the value + */ + @RaiseDSL + fun Result>.bind(): A = toOutcomeOf().bind() + + /** + * Converts `Result` to OutcomeOf and binds over the value + */ + @RaiseDSL + fun Result.bindResult(): A = toOutcome().bind() + + @RaiseDSL + fun ensureNotNull(value: A?): A { + contract { returns() implies (value != null) } + return raise.ensureNotNull(value) { Absent } + } + + /** + * Ensures the condition is met and raises an Absent otherwise. + */ + @RaiseDSL + fun ensure(condition: Boolean): Unit { + contract { returns() implies condition } + return raise.ensure(condition) { Absent } + } + + @RaiseDSL + fun OutcomeOf.bind(): A { + contract { returns() implies (this@bind is Present) } + return inner.bind().bind() + } + + /** + * Converts an ErrorOr> to an OutcomeOf and binds over the value + */ + @RaiseDSL + fun ErrorOr>.bindOption(): A { + return toOutcome().bind() + } +} diff --git a/lib/src/test/kotlin/app/cash/quiver/OutcomeOfRaiseTest.kt b/lib/src/test/kotlin/app/cash/quiver/OutcomeOfRaiseTest.kt new file mode 100644 index 0000000..0f9294b --- /dev/null +++ b/lib/src/test/kotlin/app/cash/quiver/OutcomeOfRaiseTest.kt @@ -0,0 +1,108 @@ +package app.cash.quiver + +import app.cash.quiver.extensions.ErrorOr +import app.cash.quiver.matchers.shouldBeAbsent +import app.cash.quiver.matchers.shouldBeFailure +import app.cash.quiver.matchers.shouldBePresent +import app.cash.quiver.raise.outcomeOf +import arrow.core.Either +import arrow.core.None +import arrow.core.Option +import arrow.core.some +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class OutcomeOfRaiseTest : StringSpec({ + + "outcomeOf success path" { + outcomeOf { + val a = Present(1).bind() + val b: Int = Result.success(1.some()).bind() + val c: Int = Either.Right(2).bind() + val d: Int = Either.Right(4.some()).bindOption() + val nullValue: Int? = 1 + val e: Int = ensureNotNull(nullValue) + val f: Int = nullValue.bindNull() + val g: Int = Result.success(1).bindResult() + val result = a + b + c + d + e + f + g + ensure(result > 9) + result + }.shouldBePresent().shouldBe(11) + } + + "outcomeOf Either failure path" { + outcomeOf { + val a = Present(1).bind() + val b: Int = Either.Left(Throwable("doh")).bind() + a + b + }.shouldBeFailure().message shouldBe "doh" + } + + "outcomeOf Option failure path" { + outcomeOf { + val a = Present(1).bind() + val b: Int = None.bind() + a + b + }.shouldBeAbsent() + } + "outcomeOf Nullable failure path" { + outcomeOf { + val a = Present(1).bind() + val b: Int? = null + a + (b.bindNull()) + }.shouldBeAbsent() + } + + "outcomeOf Result> failure path" { + outcomeOf { + val a = Present(1).bind() + val failure:Result> = Result.failure>(Throwable("doh")) + val b: Int = failure.bind() + a + b + }.shouldBeFailure().message shouldBe "doh" + } + + "outcomeOf Result failure path" { + outcomeOf { + val a = Present(1).bind() + val failure:Result> = Result.success(None) + val b: Int = failure.bind() + a + b + }.shouldBeAbsent() + } + + "outcomeOf Result failure path" { + outcomeOf { + val a = Present(1).bind() + val failure:Result = Result.failure(Throwable("doh")) + val b: Int = failure.bindResult() + a + b + }.shouldBeFailure().message shouldBe "doh" + } + + "outcomeOf ErrorOr> failure path" { + outcomeOf { + val a = Present(1).bind() + val failure: ErrorOr> = Either.Left(Throwable("doh")) + val b: Int = failure.bindOption() + a + b + }.shouldBeFailure().message shouldBe "doh" + } + + "outcomeOf ErrorOr failure path" { + outcomeOf { + val a = Present(1).bind() + val failure: ErrorOr = Either.Left(Throwable("doh")) + val b: Int = failure.bind() + a + b + }.shouldBeFailure().message shouldBe "doh" + } + + "outcomeOf 'ensure' failure path" { + outcomeOf { + val a = Present(3).bind() + ensure(a > 10) + }.shouldBeAbsent() + } + +})