How to find and modify field in nested case classes?
Asked Answered
B

3

9

Defined some nested case classes with List fields:

@Lenses("_") case class Version(version: Int, content: String)
@Lenses("_") case class Doc(path: String, versions: List[Version])
@Lenses("_") case class Project(name: String, docs: List[Doc])
@Lenses("_") case class Workspace(projects: List[Project])

And a sample workspace:

val workspace = Workspace(List(
  Project("scala", List(
    Doc("src/a.scala", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.scala", List(Version(1, "b11"), Version(2, "b22"))))),
  Project("java", List(
    Doc("src/a.java", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.java", List(Version(1, "b11"), Version(2, "b22"))))),
  Project("javascript", List(
    Doc("src/a.js", List(Version(1, "a11"), Version(2, "a22"))),
    Doc("src/b.js", List(Version(1, "b11"), Version(2, "b22")))))
))

Now I want to write such a method, which add a new version to a doc:

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  ???
}

I will be used as following:

  val newWorkspace = addNewVersion(workspace, "scala", "src/b.scala", Version(3, "b33"))

  println(newWorkspace == Workspace(List(
    Project("scala", List(
      Doc("src/a.scala", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.scala", List(Version(1, "b11"), Version(2, "b22"), Version(3, "b33"))))),
    Project("java", List(
      Doc("src/a.java", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.java", List(Version(1, "b11"), Version(2, "b22"))))),
    Project("javascript", List(
      Doc("src/a.js", List(Version(1, "a11"), Version(2, "a22"))),
      Doc("src/b.js", List(Version(1, "b11"), Version(2, "b22")))))
  )))

I'm not sure how to implement it in an elegant way. I tried with monocle, but it doesn't provide filter or find. My awkward solution is:

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  (_projects composeTraversal each).modify(project => {
    if (project.name == projectName) {
      (_docs composeTraversal each).modify(doc => {
        if (doc.path == docPath) {
          _versions.modify(_ ::: List(version))(doc)
        } else doc
      })(project)
    } else project
  })(workspace)
}

Is there any better solution? (Can use any libraries, not only monocle)

Bensky answered 28/10, 2015 at 11:35 Comment(0)
L
9

I just extended Quicklens with the eachWhere method to handle such a scenario, this particular method would look like this:

import com.softwaremill.quicklens._

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace = {
  workspace
    .modify(_.projects.eachWhere(_.name == projectName)
             .docs.eachWhere(_.path == docPath).versions)
    .using(vs => version :: vs)
}
Lindbom answered 30/10, 2015 at 10:14 Comment(0)
A
6

We can implement addNewVersion with optics quite nicely but there is a gotcha:

import monocle._
import monocle.macros.Lenses
import monocle.function._
import monocle.std.list._ 
import Workspace._, Project._, Doc._

def select[S](p: S => Boolean): Prism[S, S] =
   Prism[S, S](s => if(p(s)) Some(s) else None)(identity)

 def workspaceToVersions(projectName: String, docPath: String): Traversal[Workspace, List[Version]] =
  _projects composeTraversal each composePrism select(_.name == projectName) composeLens
    _docs composeTraversal each composePrism select(_.path == docPath) composeLens
    _versions

def addNewVersion(workspace: Workspace, projectName: String, docPath: String, version: Version): Workspace =
  workspaceToVersions(projectName, docPath).modify(_ :+ version)(workspace)

This will work but you might have noticed the use of select Prism which is not provided by Monocle. This is because select does not satisfy Traversal laws that state that for all t, t.modify(f) compose t.modify(g) == t.modify(f compose g).

A counter example is:

val negative: Prism[Int, Int] = select[Int](_ < 0)
(negative.modify(_ + 1) compose negative.modify(_ - 1))(-1) == 0

However, the usage of select in workspaceToVersions is completely valid because we filter on a different field that we modify. So we cannot invalidate the predicate.

Ambassador answered 28/10, 2015 at 18:7 Comment(0)
N
5

You can use Monocle's Index type to make your solution cleaner and more generic.

import monocle._, monocle.function.Index, monocle.function.all.index

def indexListBy[A, B, I](l: Lens[A, List[B]])(f: B => I): Index[A, I, B] =
  new Index[A, I, B] {
    def index(i: I): Optional[A, B] = l.composeOptional(
      Optional((_: List[B]).find(a => f(a) == i))(newA => as =>
        as.map {
          case a if f(a) == i => newA
          case a => a
        }
      )
    )
  }

implicit val projectNameIndex: Index[Workspace, String, Project] =
  indexListBy(Workspace._projects)(_.name)

implicit val docPathIndex: Index[Project, String, Doc] =
  indexListBy(Project._docs)(_.path)

This says: I know how to look up a project in a workspace using a string (the name), and a doc in a project by a string (the path). You could also put Index instances like Index[List[Project], String, Project], but since you don't own List that's arguably not ideal.

Next you can define an Optional that combines the two lookups:

def docLens(projectName: String, docPath: String): Optional[Workspace, Doc] =
  index[Workspace, String, Project](projectName).composeOptional(index(docPath))

And then your method:

def addNewVersion(
  workspace: Workspace,
  projectName: String,
  docPath: String,
  version: Version
): Workspace =
  docLens(projectName, docPath).modify(doc =>
    doc.copy(versions = doc.versions :+ version)
  )(workspace)

And you're done. This isn't really more concise than your implementation, but it's made up of more nicely composable pieces.

Ninnyhammer answered 28/10, 2015 at 17:25 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.