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
)
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)
}
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With