Skip to content

Commit

Permalink
Add withDefault and atOrElse to replace None by a default value (#886)
Browse files Browse the repository at this point in the history
* Add withDefault and atOrElse to replace None by a default value

* format

* add doc and tests for atOrElse

* fix doc
  • Loading branch information
julien-truffaut committed Oct 3, 2020
1 parent 14b7902 commit 3a2ef4e
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 2 deletions.
32 changes: 32 additions & 0 deletions core/shared/src/main/scala/monocle/function/At.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package monocle.function

import cats.Eq
import monocle.{Iso, Lens}
import monocle.std.option.withDefault

import scala.annotation.implicitNotFound
import scala.collection.immutable.{ListMap, SortedMap}
Expand All @@ -21,6 +23,36 @@ abstract class At[S, I, A] extends Serializable {
trait AtFunctions {
def at[S, I, A](i: I)(implicit ev: At[S, I, A]): Lens[S, A] = ev.at(i)

/**
* Creates a Lens that zooms into an index `i` inside `S`.
* If `S` doesn't have any data at this index, `atOrElse` insert `defaultValue`.
* {{{
* val counters = Map("id1" -> 4, "id2" -> 2)
* def mapDefaultTo0(index: String): Lens[Map[String, Int], Int] =
* atOrElse(index)(0)
*
* mapDefaultTo0("id1").get(counters) == 4
* mapDefaultTo0("id3").get(counters) == 0
*
* mapDefaultTo0("id1").modify(_ + 1)(counters) == Map("id1" -> 5, "id2" -> 2)
* mapDefaultTo0("id3").modify(_ + 1)(counters) == Map("id1" -> 4, "id2" -> 2, "id3" -> 1)
* }}}
*
* `atOrElse`` is a valid Lens only if `defaultValue` is not part of `S`.
* For example, `Map("id" -> 0)` breaks the get-set property of Lens:
* {{{
* val counters = Map("id" -> 0)
* val fromGet = mapDefaultTo0("id").get(counters) // 0
* val afterSet = mapDefaultTo0("id").set(fromGet)(counters) // Map()
*
* counters != afterSet
* }}}
*
* @see monocle.std.option.withDefault
*/
def atOrElse[S, I, A: Eq](i: I)(defaultValue: A)(implicit ev: At[S, I, Option[A]]): Lens[S, A] =
ev.at(i) composeIso withDefault(defaultValue)

/** delete a value associated with a key in a Map-like container */
def remove[S, I, A](i: I)(s: S)(implicit ev: At[S, I, Option[A]]): S =
ev.at(i).set(None)(s)
Expand Down
23 changes: 23 additions & 0 deletions core/shared/src/main/scala/monocle/std/Option.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package monocle.std

import cats.implicits._
import cats.Eq
import monocle.{Iso, PIso, PPrism, Prism}

object option extends OptionOptics
Expand All @@ -19,4 +21,25 @@ trait OptionOptics {

final def optionToDisjunction[A]: Iso[Option[A], Either[Unit, A]] =
pOptionToDisjunction[A, A]

/**
* Creates an Iso that maps `None` to `defaultValue` and inversely.
* {{{
* val defaultTo0 = withDefault(0)
* defaultTo0.get(None) == 0
* defaultTo0.get(Some(1)) == 1
* defaultTo0.reverseGet(0) == None
* defaultTo0.reverseGet(1) == Some(1)
* }}}
*
* `withDefault` is a valid Iso only if we consider the set of `A` without `defaultValue`.
* For example, `Some(0)` breaks the round-trip property of Iso:
* {{{
* defaultTo0.reverseGet(defaultTo0.get(Some(0))) == None
* }}}
*
* @see This method is called `non` in Haskell Lens.
*/
final def withDefault[A: Eq](defaultValue: A): Iso[Option[A], A] =
Iso[Option[A], A](_.getOrElse(defaultValue))(value => if (value === defaultValue) None else Some(value))
}
8 changes: 8 additions & 0 deletions example/src/test/scala/monocle/function/AtExample.scala
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,12 @@ class AtExample extends MonocleSuite {
test("remove deletes an element of a Map") {
remove("Foo")(Map("Foo" -> 1, "Bar" -> 2)) shouldEqual Map("Bar" -> 2)
}

test("atOrElse") {
(Map("One" -> 2, "Two" -> 2) applyLens atOrElse("foo")(0) modify (_ + 1)) shouldEqual Map(
"One" -> 2,
"Two" -> 2,
"foo" -> 1
)
}
}
25 changes: 23 additions & 2 deletions test/shared/src/test/scala/monocle/function/AtSpec.scala
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
package monocle.function

import cats.kernel.Eq
import monocle.MonocleSuite
import monocle.{Lens, MonocleSuite}
import monocle.law.discipline.function.AtTests

import scala.collection.immutable.ListMap

class AtSpec extends MonocleSuite {
implicit val eqListMap: Eq[ListMap[Int, String]] = Eq.fromUniversalEquals

implicit def mmapAt[K, V]: At[MMap[K, V], K, Option[V]] = At.fromIso(MMap.toMap)
implicit def mmapAt[K, V]: At[MMap[K, V], K, Option[V]] =
At.fromIso(MMap.toMap)

checkAll("fromIso", AtTests[MMap[Int, String], Int, Option[String]])

checkAll("ListMap", AtTests[ListMap[Int, String], Int, Option[String]])

def mapDefaultTo0(index: String): Lens[Map[String, Int], Int] =
atOrElse(index)(0)

test("atOrElse") {
val counters = Map("id1" -> 4, "id2" -> 2)

assert(mapDefaultTo0("id1").get(counters) == 4)
assert(mapDefaultTo0("id3").get(counters) == 0)

assert(mapDefaultTo0("id1").modify(_ + 1)(counters) == Map("id1" -> 5, "id2" -> 2))
assert(
mapDefaultTo0("id3")
.modify(_ + 1)(counters) == Map("id1" -> 4, "id2" -> 2, "id3" -> 1)
)
}

test("atOrElse can break get-set property") {
assert(mapDefaultTo0("id").set(0)(Map("id" -> 0)) == Map.empty)
}
}
13 changes: 13 additions & 0 deletions test/shared/src/test/scala/monocle/std/OptionSpec.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package monocle.std

import cats.Eq
import monocle.MonocleSuite
import monocle.law.discipline.{IsoTests, PrismTests}
import monocle.law.discipline.function.{EachTests, EmptyTests, PossibleTests}
import org.scalacheck.{Arbitrary, Cogen}

class OptionSpec extends MonocleSuite {
checkAll("some", PrismTests(some[Int]))
Expand All @@ -13,4 +15,15 @@ class OptionSpec extends MonocleSuite {
checkAll("each Option", EachTests[Option[Int], Int])
checkAll("possible Option", PossibleTests[Option[Int], Int])
checkAll("empty Option", EmptyTests[Option[Int]])

case class IntNoZero(value: Int)
object IntNoZero {
implicit val eq: Eq[IntNoZero] = Eq.fromUniversalEquals
implicit val arbitrary: Arbitrary[IntNoZero] =
Arbitrary(Arbitrary.arbitrary[Int].filterNot(_ == 0).map(IntNoZero(_)))
implicit val cogen: Cogen[IntNoZero] =
Cogen.cogenInt.contramap(_.value)
}

checkAll("withDefault Int 0", IsoTests(withDefault(IntNoZero(0))))
}

0 comments on commit 3a2ef4e

Please sign in to comment.