Skip to content

Commit

Permalink
Merge pull request #57 from adampauls/master
Browse files Browse the repository at this point in the history
Add support for .at/.atOrElse on Options, .atOrElse on Map, and .apply as an alias for .using
  • Loading branch information
adamw authored Mar 18, 2020
2 parents f0d7d3d + 827971a commit deb9c47
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 19 deletions.
48 changes: 35 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import com.softwaremill.quicklens._

case class Street(name: String)
case class Address(street: Option[Street])
case class Person(addresses: List[Address])
case class Person(addresses: Seq[Address])

val person = Person(List(
Address(Some(Street("1 Functional Rd."))),
Expand All @@ -66,7 +66,7 @@ val person = Person(List(
val p2 = person.modify(_.addresses.each.street.each.name).using(_.toUpperCase)
````

`.each` can only be used inside a `modify` and "unwraps" the container (currently supports `List`s, `Option`s and
`.each` can only be used inside a `modify` and "unwraps" the container (currently supports `Seq`s, `Option`s and
`Maps`s - only values are unwrapped for maps).
You can add support for your own containers by providing an implicit `QuicklensFunctor[C]` with the appropriate
`C` type parameter.
Expand All @@ -84,33 +84,55 @@ person
.using(_.toUpperCase)
````

**Modify specific sequence elements using .at:**
**Modify specific elements in an option/sequence/map using .at:**

````scala
person.modify(_.addresses.at(2).street.each.name).using(_.toUpperCase)
person.modify(_.addresses.at(2).street.at.name).using(_.toUpperCase)
````

Similarly to `.each`, `.at` modifies only the element at the given index. If there's no element at that index,
an `IndexOutOfBoundsException` is thrown.

**Modify specific map elements using .at:**
Similarly to `.each`, `.at` modifies only the element at the given index/key. If there's no element at that index,
an `IndexOutOfBoundsException` is thrown. In the above example, `.at(2)` selects an element in `addresses: Seq[Address]`
and `.at` selects the lone possible element in `street: Option[Street]`. If `street` is `None`, a
`NoSuchElementException` is thrown.

`.at` works for map keys as well:

````scala
case class Property(value: String)

case class Person(name: String, props: Map[String, Property])
case class PersonWithProps(name: String, props: Map[String, Property])

val person = Person(
val personWithProps = PersonWithProps(
"Joe",
Map("Role" -> Property("Programmmer"), "Age" -> Property("45"))
)

person.modify(_.props.at("Age").value).setTo("45")
personWithProps.modify(_.props.at("Age").value).setTo("45")
````

Similarly to `.each`, `.at` modifies only the element with the given key. If there's no such element,
an `NoSuchElementException` is thrown.

**Modify specific elements in an option or map with a fallback using .atOrElse:**

````scala
personWithProps.modify(_.props.atOrElse("NumReports", Property("0")).value).setTo("5")
````

If `props` contains an entry for `"NumReports"`, then `.atOrElse` behaves the same as `.at` and the second
parameter is never evaluated. If there is no entry, then `.atOrElse` will make one using the second parameter
and perform subsequent modifications on the newly instantiated default.

For Options, `.atOrElse` takes no arguments and acts similarly.

````scala
person.modify(_.addresses.at(2).street.atOrElse(Street("main street")).name).using(_.toUpperCase)
````

`.atOrElse` is currently not available for sequences because quicklens might need to insert many
elements in the list in order to ensure that one is available at a particular position, and it's not
clear that providing one default for all keys is the right behavior.

**Modify Either fields using .eachLeft and eachRight:**

````scala
Expand All @@ -129,9 +151,9 @@ val prodResource = devResource.modify(_.auth.eachLeft.token).setTo("real")
```scala
trait Animal
case class Dog(age: Int) extends Animal
case class Cat(ages: List[Int]) extends Animal
case class Cat(ages: Seq[Int]) extends Animal

case class Zoo(animals: List[Animal])
case class Zoo(animals: Seq[Animal])

val zoo = Zoo(List(Dog(4), Cat(List(3, 12, 13))))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,27 @@ package object quicklens {
}
}

implicit def optionQuicklensFunctor[A]: QuicklensFunctor[Option, A] =
new QuicklensFunctor[Option, A] {
implicit def optionQuicklensFunctor[A]: QuicklensFunctor[Option, A] with QuicklensSingleAtFunctor[Option, A] =
new QuicklensFunctor[Option, A] with QuicklensSingleAt[Option, A] {
override def map(fa: Option[A])(f: A => A) = fa.map(f)
override def at(fa: Option[A])(f: A => A) = Some(fa.map(f).get)
override def atOrElse(fa: Option[A], default: => A)(f: A => A): Option[A] = fa.orElse(Some(default)).map(f)
}

// Currently only used for [[Option]], but could be used for [[Right]]-biased [[Either]]s.
trait QuicklensSingleAtFunctor[F[_], T] {
def at(fa: F[T])(f: T => T): F[T]
def atOrElse(fa: F[T], default: => T)(f: T => T): F[T]
}

implicit class QuicklensSingleAt[F[_], T](t: F[T])(implicit f: QuicklensSingleAtFunctor[F, T]) {
@compileTimeOnly(canOnlyBeUsedInsideModify("at"))
def at: T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("at"))
def atOrElse(default: => T): T = sys.error("")
}

implicit def traversableQuicklensFunctor[F[_], A](
implicit fac: Factory[A, F[A]],
ev: F[A] => Iterable[A]
Expand All @@ -202,12 +218,16 @@ package object quicklens {
@compileTimeOnly(canOnlyBeUsedInsideModify("at"))
def at(idx: K): T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("atOrElse"))
def atOrElse(idx: K, default: => T): T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("each"))
def each: T = sys.error("")
}

trait QuicklensMapAtFunctor[F[_, _], K, T] {
def at(fa: F[K, T], idx: K)(f: T => T): F[K, T]
def atOrElse(fa: F[K, T], idx: K, default: => T)(f: T => T): F[K, T]
def each(fa: F[K, T])(f: T => T): F[K, T]
}

Expand All @@ -216,6 +236,8 @@ package object quicklens {
): QuicklensMapAtFunctor[M, K, T] = new QuicklensMapAtFunctor[M, K, T] {
override def at(fa: M[K, T], key: K)(f: T => T) =
fa.updated(key, f(fa(key))).to(fac)
override def atOrElse(fa: M[K, T], key: K, default: => T)(f: T => T) =
fa.updated(key, f(fa.getOrElse(key, default))).to(fac)
override def each(fa: M[K, T])(f: (T) => T) = {
fa.view.mapValues(f).to(fac)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,25 @@ package object quicklens {
*/
def using(mod: U => U): T = doModify(obj, mod)

/** An alias for [[using]]. Explicit calls to [[using]] are preferred over this alias, but quicklens provides
* this option because code auto-formatters (like scalafmt) will generally not keep [[modify]]/[[using]]
* pairs on the same line, leading to code like
* {{{
* x
* .modify(_.foo)
* .using(newFoo :: _)
* .modify(_.bar)
* .using(_ + newBar)
* }}}
* When using [[apply]], scalafmt will allow
* {{{
* x
* .modify(_.foo)(newFoo :: _)
* .modify(_.bar)(_ + newBar)
* }}}
* */
final def apply(mod: U => U): T = using(mod)

/**
* Transform the value of the field(s) using the given function, if the condition is true. Otherwise, returns the
* original object unchanged.
Expand Down Expand Up @@ -175,11 +194,27 @@ package object quicklens {
}
}

implicit def optionQuicklensFunctor[A]: QuicklensFunctor[Option, A] =
new QuicklensFunctor[Option, A] {
implicit def optionQuicklensFunctor[A]: QuicklensFunctor[Option, A] with QuicklensSingleAtFunctor[Option, A] =
new QuicklensFunctor[Option, A] with QuicklensSingleAtFunctor[Option, A] {
override def map(fa: Option[A])(f: A => A) = fa.map(f)
override def at(fa: Option[A])(f: A => A) = Some(fa.map(f).get)
override def atOrElse(fa: Option[A], default: => A)(f: A => A): Option[A] = fa.orElse(Some(default)).map(f)
}

// Currently only used for [[Option]], but could be used for [[Right]]-biased [[Either]]s.
trait QuicklensSingleAtFunctor[F[_], T] {
def at(fa: F[T])(f: T => T): F[T]
def atOrElse(fa: F[T], default: => T)(f: T => T): F[T]
}

implicit class QuicklensSingleAt[F[_], T](t: F[T])(implicit f: QuicklensSingleAtFunctor[F, T]) {
@compileTimeOnly(canOnlyBeUsedInsideModify("at"))
def at: T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("atOrElse"))
def atOrElse(default: => T): T = sys.error("")
}

implicit def traversableQuicklensFunctor[F[_], A](
implicit cbf: CanBuildFrom[F[A], A, F[A]],
ev: F[A] => TraversableLike[A, F[A]]
Expand All @@ -203,12 +238,16 @@ package object quicklens {
@compileTimeOnly(canOnlyBeUsedInsideModify("at"))
def at(idx: K): T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("atOrElse"))
def atOrElse(idx: K, default: => T): T = sys.error("")

@compileTimeOnly(canOnlyBeUsedInsideModify("each"))
def each: T = sys.error("")
}

trait QuicklensMapAtFunctor[F[_, _], K, T] {
def at(fa: F[K, T], idx: K)(f: T => T): F[K, T]
def atOrElse(fa: F[K, T], idx: K, default: => T)(f: T => T): F[K, T]
def each(fa: F[K, T])(f: T => T): F[K, T]
}

Expand All @@ -217,6 +256,8 @@ package object quicklens {
): QuicklensMapAtFunctor[M, K, T] = new QuicklensMapAtFunctor[M, K, T] {
override def at(fa: M[K, T], key: K)(f: T => T) =
fa.updated(key, f(fa(key))).asInstanceOf[M[K, T]]
override def atOrElse(fa: M[K, T], key: K, default: => T)(f: T => T) =
fa.updated(key, f(fa.getOrElse(key, default))).asInstanceOf[M[K, T]]
override def each(fa: M[K, T])(f: (T) => T) = {
val builder = cbf(fa)
fa.foreach { case (k, t) => builder += k -> f(t) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,9 @@ object QuicklensMacros {
@tailrec
def collectPathElements(tree: c.Tree, acc: List[PathElement]): List[PathElement] = {
def methodSupported(method: TermName) =
Seq("at", "eachWhere").contains(method.toString)
Seq("at", "eachWhere", "atOrElse").contains(method.toString)
def typeSupported(quicklensType: c.Tree) =
Seq("QuicklensEach", "QuicklensAt", "QuicklensMapAt", "QuicklensWhen", "QuicklensEither")
Seq("QuicklensEach", "QuicklensAt", "QuicklensMapAt", "QuicklensWhen", "QuicklensEither", "QuicklensSingleAt")
.exists(quicklensType.toString.endsWith)
tree match {
case q"$parent.$child" =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ class ModifyMapAtTest extends AnyFlatSpec with Matchers {
modify(m1)(_.at("K1").a5.name).using(duplicate) should be(m1dup)
}

it should "modify a non-nested map with atOrElse" in {
modify(m1)(_.atOrElse("K1", A4(A5("d4"))).a5.name).using(duplicate) should be(m1dup)
modify(m1)(_.atOrElse("K1", ???).a5.name).using(duplicate) should be(m1dup)
modify(m1)(_.atOrElse("K4", A4(A5("d4"))).a5.name).using(duplicate) should be(m1missingdup)
}

it should "modify a non-nested sorted map with case class item" in {
modify(ms1)(_.at("K1").a5.name).using(duplicate) should be(m1dup)
}
Expand All @@ -26,6 +32,10 @@ class ModifyMapAtTest extends AnyFlatSpec with Matchers {
modify(m2)(_.m3.at("K1").a5.name).using(duplicate) should be(m2dup)
}

it should "modify a nested map using atOrElse" in {
modify(m2)(_.m3.atOrElse("K4", A4(A5("d4"))).a5.name).using(duplicate) should be(m2missingdup)
}

it should "modify a non-nested map using each" in {
modify(m1)(_.each.a5.name).using(duplicate) should be(m1dupEach)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.softwaremill.quicklens

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ModifyOptionAtOrElseTest extends AnyFlatSpec with Matchers {

it should "modify a Some" in {
modify(Option(1))(_.atOrElse(3)).using(_ + 1) should be(Option(2))
}

it should "modify a None with default" in {
modify(None: Option[Int])(_.atOrElse(3)).using(_ + 1) should be(Option(4))
}

it should "modify a Option in a case class hierarchy" in {
case class Foo(a: Int)
case class Bar(foo: Foo)
case class BarOpt(maybeBar: Option[Bar])
case class BazOpt(barOpt: BarOpt)
modify(BazOpt(BarOpt(None)))(_.barOpt.maybeBar.atOrElse(Bar(Foo(5))).foo.a).using(_ + 1) should be(
BazOpt(BarOpt(Some(Bar(Foo(6)))))
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.softwaremill.quicklens

import java.util.NoSuchElementException

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ModifyOptionAtTest extends AnyFlatSpec with Matchers {

it should "modify a Option with case class item" in {
modify(Option(1))(_.at).using(_ + 1) should be(Option(2))
}

it should "modify a Option in a case class hierarchy" in {
case class Foo(a: Int)
case class Bar(foo: Foo)
case class BarOpt(maybeBar: Option[Bar])
case class BazOpt(barOpt: BarOpt)
modify(BazOpt(BarOpt(Some(Bar(Foo(4))))))(_.barOpt.maybeBar.at.foo.a).using(_ + 1) should be(
BazOpt(BarOpt(Some(Bar(Foo(5)))))
)
}

it should "crashes on missing key" in {
an[NoSuchElementException] should be thrownBy modify(None: Option[Int])(_.at).using(_ + 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class ModifySimpleTest extends AnyFlatSpec with Matchers {
modify(a5)(_.name).using(duplicate) should be(a5dup)
}

it should "modify a single-nested case class field using apply" in {
modify(a5)(_.name)(duplicate) should be(a5dup)
}

it should "modify a deeply-nested case class field" in {
modify(a1)(_.a2.a3.a4.a5.name).using(duplicate) should be(a1dup)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ object TestData {
val m2 = M2(Map("K1" -> A4(A5("d1")), "K2" -> A4(A5("d2")), "K3" -> A4(A5("d3"))))
val m1dup =
Map("K1" -> A4(A5("d1d1")), "K2" -> A4(A5("d2")), "K3" -> A4(A5("d3")))
val m1missingdup =
Map("K1" -> A4(A5("d1")), "K2" -> A4(A5("d2")), "K3" -> A4(A5("d3")), "K4" -> A4(A5("d4d4")))
val m2missingdup = M2(Map("K1" -> A4(A5("d1")), "K2" -> A4(A5("d2")), "K3" -> A4(A5("d3")), "K4" -> A4(A5("d4d4"))))
val m1dupEach =
Map("K1" -> A4(A5("d1d1")), "K2" -> A4(A5("d2d2")), "K3" -> A4(A5("d3d3")))
val m2dup = M2(Map("K1" -> A4(A5("d1d1")), "K2" -> A4(A5("d2")), "K3" -> A4(A5("d3"))))
Expand Down

0 comments on commit deb9c47

Please sign in to comment.