diff --git a/core/project.scala b/core/project.scala index 3ee47285..22f42aea 100644 --- a/core/project.scala +++ b/core/project.scala @@ -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" @@ -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" diff --git a/core/src/main/scala/besom/internal/Output.scala b/core/src/main/scala/besom/internal/Output.scala index e90b0286..dd92f9e9 100644 --- a/core/src/main/scala/besom/internal/Output.scala +++ b/core/src/main/scala/besom/internal/Output.scala @@ -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 @@ -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) @@ -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. @@ -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: @@ -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 diff --git a/core/src/main/scala/besom/internal/OutputUnzip.scala b/core/src/main/scala/besom/internal/OutputUnzip.scala new file mode 100644 index 00000000..9a8e103f --- /dev/null +++ b/core/src/main/scala/besom/internal/OutputUnzip.scala @@ -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 diff --git a/core/src/test/scala/besom/internal/OutputTest.scala b/core/src/test/scala/besom/internal/OutputTest.scala index 52cc9134..e03dfc3d 100644 --- a/core/src/test/scala/besom/internal/OutputTest.scala +++ b/core/src/test/scala/besom/internal/OutputTest.scala @@ -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 diff --git a/website/docs/changelog.md b/website/docs/changelog.md index b78e2931..b85bab4f 100644 --- a/website/docs/changelog.md +++ b/website/docs/changelog.md @@ -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) ---