Skip to content

Commit

Permalink
add more combinators to Output - issue #466
Browse files Browse the repository at this point in the history
  • Loading branch information
lbialy committed Apr 18, 2024
1 parent 360d550 commit eed4a93
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 2 deletions.
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
119 changes: 118 additions & 1 deletion core/src/main/scala/besom/internal/Output.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package besom.internal

import scala.collection.BuildFrom
import besom.internal.Output.secret

/** Output is a wrapper for a monadic effect used to model async execution that allows Pulumi to track information about dependencies
* between resources and properties of data (whether it's known or a secret for instance).
Expand Down Expand Up @@ -70,6 +71,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 +200,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 +292,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 +538,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 +605,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
164 changes: 164 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,168 @@ class OutputTest extends munit.FunSuite:
}
}

// dependent on whether we want to have ambiguous guards in for-comprehensions

This comment has been minimized.

Copy link
@lbialy

lbialy Apr 18, 2024

Author Collaborator
//
// test("output of tuples can be deconstructed in for-comprehensions") {
// given Context = DummyContext().unsafeRunSync()

// val output =
// for (a, b, c) <- Output(("string", 23, true))
// yield (a, b, c)

// assertEquals(output.getData.unsafeRunSync(), OutputData(("string", 23, true)))
// }

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
Loading

0 comments on commit eed4a93

Please sign in to comment.