You have functions with different dependencies, and want to compose them.
Remove dependencies from function arguments, and remodel the functions as partially-curried on those dependencies.
The reader monad lets us encode dependencies directly into the type, while providing composability.
A reader encapsulates a function, making it composable with other readers.
case class Reader[-E, +A](run: E => A):
def map[E2 <: E, B](f: A => B): Reader[E2, B] =
Reader(e => f(run(e)))
def flatMap[E2 <: E, B](f: A => Reader[E2, B]): Reader[E2, B] =
Reader(e => f(run(e)).run(e))
case class Folk(id: Int, name: String)
We define a bunch of database functions, each of which needs a database connection value.
trait HasConnection:
def c: java.sql.Connection
def initDb(e: HasConnection): Unit =
val s1 =
.c.prepareStatement(
e"""|CREATE TABLE IF NOT EXISTS FOLKS (
| ID INT NOT NULL,
| NAME VARCHAR(1024),
| PRIMARY KEY (ID)
|)""".stripMargin
)
.execute()
s1.close()
s1
def addFolk(f: Folk)(e: HasConnection): Unit =
val s2 =
.c.prepareStatement(
e"""|INSERT INTO FOLKS (ID, NAME)
|VALUES (?, ?)""".stripMargin
)
.setInt(1, f.id)
s2.setString(2, f.name)
s2.execute()
s2.close()
s2
def getFolk(id: Int)(e: HasConnection): Option[Folk] =
val s3 =
.c.prepareStatement(
e"SELECT ID, NAME FROM FOLKS WHERE ID = ?"
)
.setInt(1, id)
s3val rs3 = s3.executeQuery()
val folk =
if (rs3.next()) then
Some(Folk(rs3.getInt("ID"), rs3.getString("NAME")))
else None
.close()
s3
folk
def showFolk(f: Folk): String =
s"Folk ${f.id}: ${f.name}"
def close(e: HasConnection): Unit =
.c.close() e
We also define a generic way to print lines of text. In practice this could go to stdout, or a log file, etc.
trait HasPrintLine:
def printLine(x: String): Unit
To put it together, we need a database connection value which we pass around to each database function.
def demo(e: HasConnection with HasPrintLine): Unit =
initDb(e)
addFolk(Folk(1, "Folky McFolkface"))(e)
val fO = getFolk(1)(e)
val sO = fO.map(showFolk)
.foreach(e.printLine)
sOclose(e)
def e(db: String): HasConnection with HasPrintLine =
new HasConnection with HasPrintLine:
Class.forName("org.hsqldb.jdbcDriver")
override val c: java.sql.Connection =
.sql.DriverManager
java.getConnection(s"jdbc:hsqldb:mem:${db}", "sa", "")
override def printLine(x: String): Unit = println(x)
demo(e("demo"))
We can convert the imperative database functions to readers to hide the database connection values.
val initDbR: Reader[HasConnection, Unit] =
Reader(initDb(_))
def addFolkR(f: Folk): Reader[HasConnection, Unit] =
Reader(addFolk(f))
def getFolkR(id: Int): Reader[HasConnection, Option[Folk]] =
Reader(getFolk(id))
def showFolkR(id: Int): Reader[HasConnection, Option[String]] =
for
<- getFolkR(id)
fO = for
sO <- fO
f yield showFolk(f)
yield sO
val closeR: Reader[HasConnection, Unit] =
Reader(close)
def printLineR(x: String): Reader[HasPrintLine, Unit] =
Reader(_.printLine(x))
Converting our demo from before, we never have to directly deal with a database connection value.
val demoR: Reader[HasConnection with HasPrintLine, Unit] =
for
<- initDbR
_ <- addFolkR(Folk(1, "Folky McFolkface"))
_ <- getFolkR(1)
fO = fO.map(showFolk)
sO <- sO match {
_ case None => Reader(_ => ())
case Some(s) => printLineR(s)
}
= closeR
_ yield ()
.run(e("demoR")) demoR
This file is literate Scala, and can be run using Codedown:
$ curl https://earldouglas.com/posts/itof/di-to-reader.md |
codedown scala |
scala-cli -q --scala 3.1.1 --dep org.hsqldb:hsqldb:2.3.3 _.sc
Folk 1: Folky McFolkface
Folk 1: Folky McFolkface