Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add more combinators to Output - issue #466 #467

Merged
merged 2 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/project.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//> using scala "3.3.1"
//> using options "-java-output-version:11", "-Ysafe-init", "-Xmax-inlines:64"
//> using options "-Werror", "-Wunused:all", "-deprecation", "-feature"
// -language:noAutoTupling // after https://github.com/VirtusLab/scala-cli/issues/2708

//> using dep "org.virtuslab::besom-json:0.3.0"
//> using dep "com.lihaoyi::sourcecode:0.3.1"
Expand All @@ -16,7 +17,6 @@
//> using dep "com.lihaoyi::pprint:0.6.6"
//> using test.dep "org.scalameta::munit:1.0.0-M10"


//> using publish.name "besom-core"
//> using publish.organization "org.virtuslab"
//> using publish.url "https://github.com/VirtusLab/besom"
Expand Down
118 changes: 117 additions & 1 deletion core/src/main/scala/besom/internal/Output.scala
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,92 @@ class Output[+A] private[internal] (using private[besom] val ctx: Context)(
If you want to map over the value of an Output, use the map method instead."""
)

/** Recovers from a failed Output by applying the given function to the [[Throwable]].
* @param f
* the function to apply to the [[Throwable]]
* @return
* an Output with the recovered value
*/
def recover[B >: A](f: Throwable => B): Output[B] =
Output.ofData(dataResult.recover { t => Result.pure(OutputData(f(t))) })

/** Recovers from a failed Output by applying the given effectful function to the [[Throwable]]. Can be used to recover with another
* property of the same type.
* @tparam F
* the effect type
* @param f
* the effectful function to apply to the [[Throwable]]
* @return
* an Output with the recovered value
*/
def recoverWith[B >: A](f: Throwable => Output[B]): Output[B] =
Output.ofData(
dataResult.recover { t =>
f(t).getData
}
)

/** Recovers from a failed Output by applying the given effectful function to the [[Throwable]]. Can be used to recover with an effect of
* a different type.
* @tparam B
* the type of the recovered value
* @tparam F
* the effect type
* @param f
* the effectful function to apply to the [[Throwable]]
* @return
* an Output with the recovered value
*/
def recoverWith[B >: A, F[_]: Result.ToFuture](f: Throwable => F[B]): Output[B] =
Output.ofData(
dataResult.recover { t =>
Result.eval(f(t)).map(OutputData(_))
}
)

/** Applies the given function to the value of the Output and discards the result. Useful for logging or other side effects.
* @param f
* the function to apply to the value
* @return
* an Output with the original value
*/
def tap(f: A => Output[Unit]): Output[A] =
flatMap { a =>
f(a).map(_ => a)
}

/** Applies the given function to the error of the Output and discards the result. Useful for logging or other side effects.
* @param f
* the function to apply to the error
* @return
* an Output with the original value
*/
def tapError(f: Throwable => Output[Unit]): Output[A] =
Output.ofData(
dataResult.tapBoth {
case Left(t) => f(t).getData.void
case _ => Result.unit
}
)

/** Applies the given functions to the value and error of the Output and discards the results. Useful for logging or other side effects.
* Only one of the functions will be called, depending on whether the Output is a success or a failure.
* @param f
* the function to apply to the value
* @param onError
* the function to apply to the error
* @return
* an Output with the original value
*/
def tapBoth(f: A => Output[Unit], onError: Throwable => Output[Unit]): Output[A] =
Output.ofData(
dataResult.tapBoth {
case Left(t) => onError(t).getData.void
case Right(OutputData.Known(_, _, Some(a))) => f(a).getData.void
case Right(_) => Result.unit
}
)

/** Combines [[Output]] with the given [[Output]] using the given [[Zippable]], the default implementation results in a [[Tuple]].
*
* @tparam B
Expand Down Expand Up @@ -113,6 +199,13 @@ If you want to map over the value of an Output, use the map method instead."""
*/
def asSecret: Output[A] = withIsSecret(Result.pure(true))

/** Discards the value of the Output and replaces it with Unit. Useful for ignoring the value of an Output but preserving the metadata
* about dependencies, secrecy.
* @return
* an Output with the value of Unit
*/
def void: Output[Unit] = map(_ => ())

private[internal] def getData: Result[OutputData[A]] = dataResult

private[internal] def getValue: Result[Option[A]] = dataResult.map(_.getValue)
Expand Down Expand Up @@ -198,6 +291,10 @@ trait OutputFactory:
def when[A](condition: => Input[Boolean])(a: => Input.Optional[A])(using ctx: Context): Output[Option[A]] =
Output.when(condition)(a)

/** Creates an `Output` that contains Unit
*/
def unit(using Context): Output[Unit] = Output(())

end OutputFactory

/** These factory methods provide additional methods on [[Output]] instances for convenience.
Expand Down Expand Up @@ -440,6 +537,25 @@ trait OutputExtensionsFactory:
.flatMapInner(f)

end OutputOptionListOps

implicit class OutputOfTupleOps[A <: NonEmptyTuple](private val output: Output[A]):
/** Unzips the [[Output]] of a non-empty tuple into a tuple of [[Output]]s of the same arity. This operation is equivalent to:
*
* {{{o: Output[(A, B, C)] => (o.map(_._1), o.map(_._2), o.map(_._3))}}}
*
* and therefore will yield three descendants of the original [[Output]]. Evaluation of the descendants will cause the original
* [[Output]] to be evaluated as well and may therefore lead to unexpected side effects. This is usually not a problem with properties
* of resources but can be surprising if other effects are subsumed into the original [[Output]]. If this behavior is not desired,
* consider using [[unzipOutput]] instead.
*
* @tparam Output
* the type of the [[Output]]s
* @return
* a tuple of [[Output]]s
*/
inline def unzip: Tuple.Map[A, Output] = OutputUnzip.unzip(output)
end OutputOfTupleOps

end OutputExtensionsFactory

object Output:
Expand Down Expand Up @@ -488,7 +604,7 @@ object Output:
): Output[A] =
new Output[A](ctx.registerTask(Result.eval(value)).map(OutputData(_)))

def fail[A](t: Throwable)(using ctx: Context): Output[Nothing] =
def fail(t: Throwable)(using ctx: Context): Output[Nothing] =
new Output[Nothing](ctx.registerTask(Result.fail(t)))

def apply[A](value: => Result[A])(using
Expand Down
49 changes: 49 additions & 0 deletions core/src/main/scala/besom/internal/OutputUnzip.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package besom.internal

import scala.quoted.*

object OutputUnzip:
inline def unzip[A <: NonEmptyTuple](output: Output[A]): Tuple.Map[A, Output] = ${ unzipImpl[A]('output) }

// essentially we're performing Output[(A, B, C)] => (Output[A], Output[B], Output[C]) transformation
def unzipImpl[A <: NonEmptyTuple: Type](outputA: Expr[Output[A]])(using Quotes): Expr[Tuple.Map[A, Output]] =
import quotes.reflect.*

// tuple xxl is represented as a linked list of types via AppliedType, we extract types recursively
def extractTypesFromTupleXXL(tup: TypeRepr): List[TypeRepr] =
tup match
// tuple cons element
case AppliedType(tpe, types) if tpe =:= TypeRepr.of[scala.*:] =>
// for tuple cons, we expect exactly 2 types, type and tail consisting of another scala.*:
types match
case tpe :: tail :: Nil => tpe :: extractTypesFromTupleXXL(tail)
case Nil => Nil
case _ =>
report.errorAndAbort(s"Expected an AppliedType for scala.:* type (exactly 2 elems), got: ${types.map(_.show)}")
// final element in the tuple
case tpe if tpe =:= TypeRepr.of[EmptyTuple] => Nil
case _ => report.errorAndAbort(s"Expected an AppliedType for scala.:* type, got: ${tup.show}")

val tupleType = TypeRepr.of[A]
val tupleTypes = tupleType match
case AppliedType(tpe, types) if tpe =:= TypeRepr.of[scala.*:] => extractTypesFromTupleXXL(tupleType)
case AppliedType(tpe, types) => types
case _ => report.errorAndAbort(s"Expected a tuple type, got: ${tupleType.show}")

val mapExprs = tupleTypes.zipWithIndex.map { (tpe, idx) =>
val idxExpr = Expr(idx)
tpe.asType match
case '[t] =>
// we use Tuple#toArray to avoid _23 problem (compiler generates accessors up to 22 elems)
'{ $outputA.map[t](x => x.toArray($idxExpr).asInstanceOf[t]) }
}

val tupleOfOutputs = mapExprs.foldLeft[Expr[Tuple]](Expr.ofTuple(EmptyTuple)) { (acc, expr) =>
'{ $acc :* $expr }
}

'{ $tupleOfOutputs.asInstanceOf[Tuple.Map[A, Output]] }

end unzipImpl

end OutputUnzip
152 changes: 152 additions & 0 deletions core/src/test/scala/besom/internal/OutputTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -613,4 +613,156 @@ class OutputTest extends munit.FunSuite:
}
}

test("unzip combinator is able to unzip an Output of a tuple into a tuple of Outputs") {
object extensions extends OutputExtensionsFactory
import extensions.*

given Context = DummyContext().unsafeRunSync()

val o3 = Output(("string", 23, true))

val (str, int, bool) = o3.unzip

assertEquals(str.getData.unsafeRunSync(), OutputData("string"))
assertEquals(int.getData.unsafeRunSync(), OutputData(23))
assertEquals(bool.getData.unsafeRunSync(), OutputData(true))

// explicitly tuple of 20 elements
val tupleOf22Elems = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22)

val o22 = Output(tupleOf22Elems)

val tupleOf22Outputs = o22.unzip

assertEquals(tupleOf22Outputs.size, 22)

// explicitly tuple of 23 elements, testing tuple xxl
val tupleOf23Elems = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, "23")

val o23 = Output(tupleOf23Elems)

val tupleOf23Outputs = o23.unzip

assertEquals(tupleOf23Outputs.size, 23)

tupleOf23Outputs.toArray.map(_.asInstanceOf[Output[Int | String]]).zipWithIndex.foreach { (output, idx) =>
if idx == 22 then assertEquals(output.getData.unsafeRunSync(), OutputData("23"))
else assertEquals(output.getData.unsafeRunSync(), OutputData(idx + 1))
}
}

test("recover combinator is able to recover from a failed Output") {
given Context = DummyContext().unsafeRunSync()

val failedOutput: Output[Int] = Output.fail(Exception("error"))

val recoveredOutput = failedOutput.recover { case _: Exception =>
42
}

assertEquals(recoveredOutput.getData.unsafeRunSync(), OutputData(42))
}

test("recoverWith combinator is able to recover from a failed Output with another Output") {
given Context = DummyContext().unsafeRunSync()

val failedOutput: Output[Int] = Output.fail(Exception("error"))

val recoveredOutput = failedOutput.recoverWith { case _: Exception =>
Output(42)
}

assertEquals(recoveredOutput.getData.unsafeRunSync(), OutputData(42))
}

test("recoverWith combinator is able to subsume an effect like flatMap") {
import scala.concurrent.Future
import besom.*

given Context = DummyContext().unsafeRunSync()

val failedOutput: Output[Int] = Output.fail(Exception("error"))

val recoveredOutput = failedOutput.recoverWith { case _: Exception =>
Future.successful(42)
}

assertEquals(recoveredOutput.getData.unsafeRunSync(), OutputData(42))
}

test("tap combinator is able to tap into the value of an Output") {
object Output extends OutputFactory

given Context = DummyContext().unsafeRunSync()

var tappedValue = 0

val output = Output(42).tap { value =>
tappedValue = value
Output.unit
}

assertEquals(output.getData.unsafeRunSync(), OutputData(42))
assertEquals(tappedValue, 42)
}

test("tapError combinator is able to tap into the error of a failed Output") {
object Output extends OutputFactory

given Context = DummyContext().unsafeRunSync()

var tappedError: Throwable = new RuntimeException("everything is fine")

val failedOutput = Output.fail(new RuntimeException("error")).tapError { error =>
tappedError = error
Output.unit
}

interceptMessage[RuntimeException]("error")(failedOutput.getData.unsafeRunSync())
assertEquals(tappedError.getMessage, "error")
}

test("tapBoth combinator is able to tap into the value and error of an Output") {
object Output extends OutputFactory

given Context = DummyContext().unsafeRunSync()

var tappedValue = 0
var tappedError: Throwable = new RuntimeException("everything is fine")

val output = Output(42).tapBoth(
value => {
tappedValue = value
Output.unit
},
error => {
tappedError = error
Output.unit
}
)

assertEquals(output.getData.unsafeRunSync(), OutputData(42))
assertEquals(tappedValue, 42)
assertEquals(tappedError.getMessage, "everything is fine")

tappedValue = 0

val failedOutput = Output
.fail(new RuntimeException("error"))
.tapBoth(
value => {
tappedValue = value
Output.unit
},
error => {
tappedError = error
Output.unit
}
)

interceptMessage[RuntimeException]("error")(failedOutput.getData.unsafeRunSync())
assertEquals(tappedValue, 0)
assertEquals(tappedError.getMessage, "error")
}

end OutputTest
16 changes: 16 additions & 0 deletions website/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@
title: Changelog
---

0.3.1 (19-04-2024)
---

* Added new combinators on `Output`:
* `recover` allows to map an error inside of a failed `Output` to a new value
* `recoverWith` allows the same but using an effectful function returning either an `Output` or any
other supported effect, e.g.: `Future`, `IO` or `Task`
* `tap` allows to access the value of an `Output` and apply an effectful function to it while
discarding said function's results
* `tapError` allows the same but for an error of a failed `Output`
* `tapBoth` takes two effectful function and allows to access either error or value of an `Output` by
applying one of them to the contents of the `Output`
* `void` discards the value of an `Output`, comes with a static constructor function - `Output.unit`
* `unzip` can be called on an `Output` of a tuple to receive a tuple of `Output`s, all of which are
descendents of the original `Output`

0.3.0 (16-04-2024)
---

Expand Down
Loading