diff --git a/cats/src/io/github/iltotore/iron/cats.scala b/cats/src/io/github/iltotore/iron/cats.scala index 9541243f..0ccf25b4 100644 --- a/cats/src/io/github/iltotore/iron/cats.scala +++ b/cats/src/io/github/iltotore/iron/cats.scala @@ -5,7 +5,8 @@ import _root_.cats.kernel.{CommutativeMonoid, Hash, LowerBounded, PartialOrder, import _root_.cats.syntax.either.* import _root_.cats.{Eq, Monoid, Order, Show} import _root_.cats.data.Validated.{Invalid, Valid} -import io.github.iltotore.iron.constraint.numeric.{Greater, Less, Positive, Negative} +import _root_.cats.Functor +import io.github.iltotore.iron.constraint.numeric.{Greater, Less, Negative, Positive} import scala.util.NotGiven @@ -172,6 +173,10 @@ object cats extends IronCatsInstances: * Represent all Cats' typeclass instances for Iron. */ private trait IronCatsInstances extends IronCatsLowPriority, RefinedTypeOpsCats: + + given [F[_]](using functor: Functor[F]): MapLogic[F] with + + override def map[A, B](wrapper: F[A], f: A => B): F[B] = functor.map(wrapper)(f) // The `NotGiven` implicit parameter is mandatory to avoid ambiguous implicit error when both Eq[A] and Hash[A]/PartialOrder[A] exist inline given [A, C](using inline ev: Eq[A], notHashOrOrder: NotGiven[Hash[A] | PartialOrder[A]]): Eq[A :| C] = ev.asInstanceOf[Eq[A :| C]] diff --git a/cats/test/src/io/github/iltotore/iron/CatsSuite.scala b/cats/test/src/io/github/iltotore/iron/CatsSuite.scala index cfffd0b6..ec53085a 100644 --- a/cats/test/src/io/github/iltotore/iron/CatsSuite.scala +++ b/cats/test/src/io/github/iltotore/iron/CatsSuite.scala @@ -145,4 +145,9 @@ object CatsSuite extends TestSuite: val validatedNelWithSucceedingPredicate = Temperature.validatedNel(100) assert(validatedNelWithSucceedingPredicate == Valid(Temperature(100)), "valid should contain result of 'apply'") } + + test("refineAll") { + test - assert(Temperature.optionAll(NonEmptyList.of(1, 2, -3)).isEmpty) + test - assert(Temperature.optionAll(NonEmptyList.of(1, 2, 3)).contains(NonEmptyList.of(Temperature(1), Temperature(2), Temperature(3)))) + } } diff --git a/docs/_docs/reference/refinement.md b/docs/_docs/reference/refinement.md index 5176236b..286ac8c1 100644 --- a/docs/_docs/reference/refinement.md +++ b/docs/_docs/reference/refinement.md @@ -245,6 +245,17 @@ createUser("Iltotore", "abc123 ") //Left("Your password should be alphanumeric" Note: Accumulative versions exist for [Cats](../modules/cats.md) and [ZIO](../modules/zio.md). +### Refining first order types + +Iron provides utility methods to easily refine first order types (e.g container types like `List`, `Future`, `IO`...). + +```scala +List(1, 2, 3).refineAllUnsafe[Positive] //List(1, 2, 3): List[Int :| Positive] +List(1, 2, -3).refineAllUnsafe[Positive] //IllegalArgumentException +``` + +Variants exist for `Option/Either`, `assume`, `...Further` as well as `RefinedTypeOps` constructors. + ## Assuming constraints Sometimes, you know that your value always passes (possibly at runtime) a constraint. For example: diff --git a/main/src/io/github/iltotore/iron/MapLogic.scala b/main/src/io/github/iltotore/iron/MapLogic.scala new file mode 100644 index 00000000..db82a503 --- /dev/null +++ b/main/src/io/github/iltotore/iron/MapLogic.scala @@ -0,0 +1,27 @@ +package io.github.iltotore.iron + +import scala.collection.IterableOnceOps +import scala.concurrent.{ExecutionContext, Future} + +/** + * A typeclass providing a `map` method. Mainly used to abstract over Cats and ZIO Prelude. + * + * @tparam F the wrapper type + */ +trait MapLogic[F[_]]: + + def map[A, B](wrapper: F[A], f: A => B): F[B] + +object MapLogic: + + given [C, CC[x] <: IterableOnceOps[x, CC, C]]: MapLogic[CC] with + + def map[A, B](wrapper: CC[A], f: A => B): CC[B] = wrapper.map(f) + + given [L]: MapLogic[[x] =>> Either[L, x]] with + + override def map[A, B](wrapper: Either[L, A], f: A => B): Either[L, B] = wrapper.map(f) + + given (using ExecutionContext): MapLogic[Future] with + + override def map[A, B](wrapper: Future[A], f: A => B): Future[B] = wrapper.map(f) \ No newline at end of file diff --git a/main/src/io/github/iltotore/iron/RefinedTypeOps.scala b/main/src/io/github/iltotore/iron/RefinedTypeOps.scala index b274504b..c6dd0e31 100644 --- a/main/src/io/github/iltotore/iron/RefinedTypeOps.scala +++ b/main/src/io/github/iltotore/iron/RefinedTypeOps.scala @@ -2,6 +2,8 @@ package io.github.iltotore.iron import scala.compiletime.summonInline import scala.reflect.TypeTest +import scala.util.boundary +import scala.util.boundary.break /** * Utility trait for new types' companion object. @@ -30,13 +32,23 @@ trait RefinedTypeOps[A, C, T](using private val _rtc: RuntimeConstraint[A, C]): inline def apply(value: A :| C): T = value.asInstanceOf[T] /** - * Refine the given value at runtime, assuming the constraint holds. + * Refine the given value, assuming the constraint holds. * * @return a constrained value, without performing constraint checks. - * @see [[apply]], [[applyUnsafe]]. + * @see [[assumeAll]], [[apply]], [[applyUnsafe]]. */ inline def assume(value: A): T = value.asInstanceOf[T] + /** + * Refine the given value at runtime. + * + * @return this value as [[T]]. + * @throws an [[IllegalArgumentException]] if the constraint is not satisfied. + * @see [[fromIronType]], [[either]], [[option]]. + */ + inline def applyUnsafe(value: A): T = + if rtc.test(value) then value.asInstanceOf[T] else throw new IllegalArgumentException(rtc.message) + /** * Refine the given value at runtime, resulting in an [[Either]]. * @@ -48,8 +60,6 @@ trait RefinedTypeOps[A, C, T](using private val _rtc: RuntimeConstraint[A, C]): /** * Refine the given value at runtime, resulting in an [[Option]]. - * - * @param constraint the constraint to test with the value to refine. * @return an Option containing this value as [[T]] or [[None]]. * @see [[fromIronType]], [[either]], [[applyUnsafe]]. */ @@ -57,14 +67,52 @@ trait RefinedTypeOps[A, C, T](using private val _rtc: RuntimeConstraint[A, C]): Option.when(rtc.test(value))(value.asInstanceOf[T]) /** - * Refine the given value at runtime. + * Refine the given value(s), assuming the constraint holds. * - * @return this value as [[T]]. - * @throws an [[IllegalArgumentException]] if the constraint is not satisfied. - * @see [[fromIronType]], [[either]], [[option]]. + * @return a wrapper of constrained values, without performing constraint checks. + * @see [[assume]]. */ - inline def applyUnsafe(value: A): T = - if rtc.test(value) then value.asInstanceOf[T] else throw new IllegalArgumentException(rtc.message) + inline def assumeAll[F[_]](wrapper: F[A]): F[T] = wrapper.asInstanceOf[F[T]] + + /** + * Refine the given value(s) at runtime. + * + * @return the given values as [[T]]. + * @throws IllegalArgumentException if the constraint is not satisfied. + * @see [[applyUnsafe]]. + */ + inline def applyAllUnsafe[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): F[T] = + mapLogic.map(wrapper, applyUnsafe(_)) + + /** + * Refine the given value(s) at runtime, resulting in an [[Either]]. + * + * @return a [[Right]] containing the given values as [[T]] or a [[Left]] containing the constraint message. + * @see [[either]]. + */ + inline def eitherAll[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): Either[String, F[T]] = + boundary: + Right(mapLogic.map( + wrapper, + either(_) match + case Right(value) => value + case Left(error) => break(Left(error)) + )) + + /** + * Refine the given value at runtime, resulting in an [[Option]]. + * + * @return an Option containing the refined values as `F[T]` or [[None]]. + * @see [[option]]. + */ + inline def optionAll[F[_]](wrapper: F[A])(using mapLogic: MapLogic[F]): Option[F[T]] = + boundary: + Some(mapLogic.map( + wrapper, + option(_) match + case Some(value) => value + case None => break(None) + )) def unapply(value: T): Option[A :| C] = Some(value.asInstanceOf[A :| C]) diff --git a/main/src/io/github/iltotore/iron/conversion.scala b/main/src/io/github/iltotore/iron/conversion.scala index 4d9b3d09..b773a2a6 100644 --- a/main/src/io/github/iltotore/iron/conversion.scala +++ b/main/src/io/github/iltotore/iron/conversion.scala @@ -3,6 +3,8 @@ package io.github.iltotore.iron import io.github.iltotore.iron.constraint.collection.ForAll import scala.language.implicitConversions +import scala.util.boundary +import scala.util.boundary.break /** * Implicitly refine at compile-time the given value. @@ -62,10 +64,10 @@ implicit inline def autoDistribute[A, I[_] <: Iterable[?], C1, C2](inline iterab extension [A, C1](value: A :| C1) /** - * Refine the given value again at runtime, assuming the constraint holds. + * Refine the given value again, assuming the constraint holds. * * @return a constrained value, without performing constraint checks. - * @see [[assume]]. + * @see [[assume]], [[assumeAllFurther]]. */ inline def assumeFurther[C2]: A :| (C1 & C2) = (value: A).assume[C1 & C2] @@ -112,6 +114,59 @@ extension [A, C1](value: A :| C1) inline def refineFurtherOption[C2](using inline constraint: Constraint[A, C2]): Option[A :| (C1 & C2)] = (value: A).refineOption[C2].map(_.assumeFurther[C1]) +extension[F[_], A, C1] (wrapper: F[A :| C1]) + + /** + * Refine the given value(s) again, assuming the constraint holds. + * + * @return the constrained values, without performing constraint checks. + * @see [[assume]], [[assumeFurther]]. + */ + inline def assumeAllFurther[C2]: F[A :| (C1 & C2)] = wrapper.asInstanceOf[F[A :| (C1 & C2)]] + + /** + * Refine the given value(s) again at runtime. + * + * @param constraint the new constraint to test. + * @return the given values refined with `C1 & C2`. + * @throws IllegalArgumentException if the constraint is not satisfied. + * @see [[refineUnsafe]], [[refineFurtherUnsafe]]. + */ + inline def refineAllFurtherUnsafe[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): F[A :| (C1 & C2)] = + mapLogic.map(wrapper, _.refineFurtherUnsafe[C2]) + + /** + * Refine the given value(s) again at runtime, resulting in an [[Either]]. + * + * @param constraint the new constraint to test. + * @return a [[Right]] containing the given values refined with `C1 & C2` or a [[Left]] containing the constraint message. + * @see [[refineEither]], [[refineAllFurtherEither]]. + */ + inline def refineAllFurtherEither[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): Either[String, F[A :| (C1 & C2)]] = + boundary: + Right(mapLogic.map( + wrapper, + _.refineFurtherEither[C2] match + case Right(value) => value + case Left(error) => break(Left(error)) + )) + + /** + * Refine the given value(s) again at runtime, resulting in an [[Option]]. + * + * @param constraint the new constraint to test. + * @return a [[Option]] containing the given values refined with `C1 & C2` or [[None]]. + * @see [[refineOption]], [[refineFurtherOption]]. + */ + inline def refineAllFurtherOption[C2](using mapLogic: MapLogic[F], inline constraint: Constraint[A, C2]): Option[F[A :| (C1 & C2)]] = + boundary: + Some(mapLogic.map( + wrapper, + _.refineFurtherOption[C2] match + case Some(value) => value + case None => break(None) + )) + extension [A, C1, C2](value: A :| C1 :| C2) /** diff --git a/main/src/io/github/iltotore/iron/package.scala b/main/src/io/github/iltotore/iron/package.scala index 3dc0730c..148f45e7 100644 --- a/main/src/io/github/iltotore/iron/package.scala +++ b/main/src/io/github/iltotore/iron/package.scala @@ -5,7 +5,8 @@ import io.github.iltotore.iron.macros import scala.Console.{CYAN, RESET} import scala.compiletime.{codeOf, error, summonInline} import scala.reflect.TypeTest -import scala.util.NotGiven +import scala.util.{boundary, NotGiven} +import scala.util.boundary.break /** * The main package of Iron. Contains: @@ -48,11 +49,10 @@ end IronType extension [A](value: A) /** - * Refine the given value at runtime, assuming the constraint holds. + * Refine the given value, assuming the constraint holds. * - * @param constraint the constraint to test with the value to refine. * @return a constrained value, without performing constraint checks. - * @see [[autoRefine]], [[refineUnsafe]]. + * @see [[assumeAll]], [[autoRefine]], [[refineUnsafe]]. */ inline def assume[B]: A :| B = value @@ -98,4 +98,59 @@ extension [A](value: A) * @see [[autoRefine]], [[refineUnsafe]], [[refineEither]]. */ inline def refineOption[B](using inline constraint: Constraint[A, B]): Option[A :| B] = - Option.when(constraint.test(value))(value) \ No newline at end of file + Option.when(constraint.test(value))(value) + +extension [F[_], A](wrapper: F[A]) + + /** + * Refine the contained value(s), assuming the constraint holds. + * + * @return constrained values, without performing constraint checks. + * @see [[assume]], [[autoRefine]], [[refineUnsafe]]. + */ + inline def assumeAll[B]: F[A :| B] = wrapper + + /** + * Refine the given value(s) at runtime. + * + * @param constraint the constraint to test with the value to refine. + * @return the given values as [[IronType]]. + * @throws an [[IllegalArgumentException]] if the constraint is not satisfied. + * @see [[refineUnsafe]]. + */ + inline def refineAllUnsafe[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): F[A :| B] = + mapLogic.map(wrapper, _.refineUnsafe[B]) + + + /** + * Refine the given value(s) at runtime, resulting in an [[Either]]. + * + * @param constraint the constraint to test with the value to refine. + * @return a [[Right]] containing the given values as [[IronType]] or a [[Left]] containing the constraint message. + * @see [[refineEither]]. + */ + inline def refineAllEither[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): Either[String, F[A :| B]] = + boundary: + Right(mapLogic.map( + wrapper, + _.refineEither[B] match + case Right(value) => value + case Left(error) => break(Left(error)) + )) + + /** + * Refine the given value(s) at runtime, resulting in an [[Option]]. + * + * @param constraint the constraint to test with the value to refine. + * @return a [[Some]] containing the given values as [[IronType]] or [[None]]. + * @see [[refineOption]]. + */ + inline def refineAllOption[B](using mapLogic: MapLogic[F], inline constraint: Constraint[A, B]): Option[F[A :| B]] = + boundary: + Some(mapLogic.map( + wrapper, + _.refineOption[B] match + case Some(value) => value + case None => break(None) + )) + diff --git a/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala b/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala index 748ea2f6..3452ccc2 100644 --- a/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala +++ b/main/test/src/io/github/iltotore/iron/testing/RefinedTypeOpsSuite.scala @@ -32,6 +32,13 @@ object RefinedTypeOpsSuite extends TestSuite: assert(t2 == Temperature(15.0)) } + test("assume") - assert(Temperature.assume(-15) == -15.0.asInstanceOf[Temperature]) + + test("applyUnsafe") { + test - assertMatch(Try(Temperature.applyUnsafe(-100))) { case Failure(e) if e.getMessage == "Should be strictly positive" => } + test - assert(Temperature.applyUnsafe(100) == Temperature(100)) + } + test("either") { val eitherWithFailingPredicate = Temperature.either(-5.0) assert(eitherWithFailingPredicate == Left("Should be strictly positive")) @@ -46,12 +53,22 @@ object RefinedTypeOpsSuite extends TestSuite: assert(fromWithSucceedingPredicate.contains(Temperature(100))) } - test("applyUnsafe") { - test - assertMatch(Try(Temperature.applyUnsafe(-100))) { case Failure(e) if e.getMessage == "Should be strictly positive" => } - test - assert(Temperature.applyUnsafe(100) == Temperature(100)) + test("assumeAll") - assert(Temperature.assumeAll(List(1, -15)) == List(1, -15).asInstanceOf[List[Temperature]]) + + test("applyAllUnsafe") { + test - assertMatch(Try(Temperature.applyAllUnsafe(List(1, 2, -3)))) { case Failure(e) if e.getMessage == "Should be strictly positive" => } + test - assert(Temperature.applyAllUnsafe(List(1, 2, 3)) == List(Temperature(1), Temperature(2), Temperature(3))) } - test("assume") - assert(Temperature.assume(-15) == -15.0.asInstanceOf[Temperature]) + test("either") { + test - assert(Temperature.eitherAll(List(1, 2, -3)) == Left("Should be strictly positive")) + test - assert(Temperature.eitherAll(List(1, 2, 3)) == Right(List(Temperature(1), Temperature(2), Temperature(3)))) + } + + test("option") { + test - assert(Temperature.optionAll(List(1, 2, -3)).isEmpty) + test - assert(Temperature.optionAll(List(1, 2, 3)).contains(List(Temperature(1), Temperature(2), Temperature(3)))) + } test("nonOpaque") { val moisture = Moisture(11) diff --git a/zio/src/io/github/iltotore/iron/zio.scala b/zio/src/io/github/iltotore/iron/zio.scala index 14b686c7..3cb6b636 100644 --- a/zio/src/io/github/iltotore/iron/zio.scala +++ b/zio/src/io/github/iltotore/iron/zio.scala @@ -1,7 +1,7 @@ package io.github.iltotore.iron import _root_.zio.NonEmptyChunk -import _root_.zio.prelude.{Debug, Equal, Hash, Ord, Validation} +import _root_.zio.prelude.{Covariant, Debug, Equal, Hash, Ord, Validation} object zio extends RefinedTypeOpsZio: @@ -34,6 +34,10 @@ object zio extends RefinedTypeOpsZio: */ def validation(value: A): Validation[String, T] = Validation.fromPredicateWith(ops.rtc.message)(value)(ops.rtc.test(_)).asInstanceOf[Validation[String, T]] + + given [F[+_]](using covariant: Covariant[F]): MapLogic[F] with + + override def map[A, B](wrapper: F[A], f: A => B): F[B] = covariant.map(f)(wrapper) private trait RefinedTypeOpsZio extends RefinedTypeOpsZioLowPriority: diff --git a/zio/test/src/io/github/iltotore/iron/ZIOSuite.scala b/zio/test/src/io/github/iltotore/iron/ZIOSuite.scala index 96943770..89e54c88 100644 --- a/zio/test/src/io/github/iltotore/iron/ZIOSuite.scala +++ b/zio/test/src/io/github/iltotore/iron/ZIOSuite.scala @@ -4,13 +4,11 @@ import io.github.iltotore.iron.constraint.all.* import utest.{Show as _, *} import scala.runtime.stdLibPatches.Predef.assert -import io.github.iltotore.iron.zio as ironZio +import io.github.iltotore.iron.zio.{*, given} import _root_.zio.prelude.ZValidation import _root_.zio.Chunk import _root_.zio.NonEmptyChunk -import io.github.iltotore.iron.zio.validation - object ZIOSuite extends TestSuite: val tests: Tests = Tests { @@ -22,4 +20,9 @@ object ZIOSuite extends TestSuite: ZValidation.Failure[String, String](Chunk.empty, NonEmptyChunk.single("Should be strictly positive")) ) } + + test("refineAll") { + test - assert(Temperature.optionAll(NonEmptyChunk(1, 2, 3)).contains(NonEmptyChunk(Temperature(1), Temperature(2), Temperature(3)))) + test - assert(Temperature.optionAll(NonEmptyChunk(1, 2, -3)).isEmpty) + } }