From Monad Transformers to an Effect System

March 22, 2018

Given:

Let:

Effect

import scala.concurrent.{ExecutionContext => EC}
import scala.concurrent.Future
case class Effect[-R, +E, +A](unsafeRun: R => Future[Either[E, A]])(implicit ec: EC):

  def map[B](f: A => B)(implicit ec: EC): Effect[R, E, B] =
    Effect(r =>
      unsafeRun(r).map {
        case Left(e)  => Left(e)
        case Right(a) => Right(f(a))
      }
    )

  def flatMap[R2 <: R, E2 >: E, B](f: A => Effect[R2, E2, B])(implicit ec: EC): Effect[R2, E2, B] =
    Effect(r =>
      unsafeRun(r).flatMap {
        case Left(e)  => Future(Left(e))
        case Right(a) => f(a).unsafeRun(r)
      }
    )

  def >>[R2 <: R, E2 >: E, B](x: Effect[R2, E2, B])(implicit ec: EC): Effect[R2, E2, B] =
    flatMap(_ => x)

object Effect:

  def success[E, A](a: => A)(implicit ec: EC): Effect[Any, E, A] =
    Effect(_ => Future(Right(a)))

  def failure[E, A](e: => E)(implicit ec: EC): Effect[Any, E, A] =
    Effect(_ => Future(Left(e)))

JDBC services

sealed trait JdbcError
case class Caught(throwable: Throwable) extends JdbcError
case class NotFound(message: String) extends JdbcError

object Jdbc:

  import java.sql.Connection
  import scala.concurrent.Await
  import scala.concurrent.duration.Duration
  import scala.util.Failure
  import scala.util.Success
  import scala.util.Try

  type Jdbc[A] = Effect[Connection, JdbcError, A]

  def transact[A](s: Jdbc[A], c: Connection)(implicit ec: EC): Future[Either[JdbcError, A]] =
    c.synchronized {
      val ac = c.getAutoCommit
      c.setAutoCommit(false)
      val ea =
        s.unsafeRun(c).map {
          case r @ Right(_) =>
            c.commit()
            r
          case l @ Left(_) =>
            c.rollback()
            l
        }
      c.setAutoCommit(ac)
      ea
    }

  def unsafeRun[A](s: Jdbc[A], c: Connection)(implicit ec: EC): Either[JdbcError, A] =
    Await.result(Jdbc.transact(s, c), Duration(100, "millis"))

  def update(u: String)(implicit ec: EC): Jdbc[Unit] =
    Effect(c =>
      Future(
        Try {
          val s = c.createStatement()
          s.executeUpdate(u)
          s.close()
        } match {
          case Success(_) => Right(())
          case Failure(t) => Left(Caught(t))
        }
      )
    )

  def query[A](k: Connection => A)(implicit ec: EC): Jdbc[A] =
    Effect(c =>
      Future(
        Try {
          k(c)
        } match {
          case Success(x) => Right(x)
          case Failure(t) => Left(Caught(t))
        }
      )
    )

Example

object MyDatabase:

  import Jdbc.Jdbc

  def init(implicit ec: EC): Jdbc[Unit] =
    Jdbc.update("CREATE TABLE MESSAGES (`LANG` VARCHAR(5), `VALUE` VARCHAR(128))") >>
      Jdbc.update("INSERT INTO MESSAGES (`LANG`, `VALUE`) VALUES ('en', 'Hello, world!')") >>
      Jdbc.update("INSERT INTO MESSAGES (`LANG`, `VALUE`) VALUES ('es', '¡Hola, mundo!')")

  def get(lang: String)(implicit ec: EC): Jdbc[String] =
    Jdbc
      .query(c =>
        val q = "SELECT `VALUE` FROM MESSAGES WHERE `LANG` = ?"
        val s = c.prepareStatement(q)
        s.setString(1, lang)
        val r = s.executeQuery()
        val vo = if (r.next()) then Some(r.getString("VALUE")) else None
        s.close()
        vo
      )
      .flatMap {
        case Some(x) => Effect.success(x)
        case None    => Effect.failure(NotFound(s"no message for lang=${lang}"))
      }
object MyProgram:

  import Jdbc.Jdbc

  def succeeds(implicit ec: EC): Jdbc[Map[String, String]] =
    for
      en <- MyDatabase.get("en")
      es <- MyDatabase.get("es")
    yield Map("en" -> en, "es" -> es)

  def fails(implicit ec: EC): Jdbc[Map[String, String]] =
    for
      fr <- MyDatabase.get("fr")
      es <- MyDatabase.get("es")
    yield Map("fr" -> fr, "es" -> es)

Usage

object Main extends App:

  import java.sql.DriverManager

  implicit val ec: EC = EC.global

  Class.forName("org.h2.Driver")

  val c = DriverManager.getConnection("jdbc:h2:mem:db", "sa", "")

  println(Jdbc.unsafeRun(MyDatabase.init, c))
  // Right(())

  println(Jdbc.unsafeRun(MyProgram.succeeds, c))
  // Right(Map(en -> Hello, world!, es -> ¡Hola, mundo!))

  println(Jdbc.unsafeRun(MyProgram.fails, c))
  // Left(NotFound(no message for lang=fr))

Demo

This file is literate Scala, and can be run using Codedown:

$ curl https://earldouglas.com/scala/service.md |
  codedown scala |
  scala-cli -q --scala 3.1.3 --dep com.h2database:h2:2.1.214 _.scala
Right(())
Right(Map(en -> Hello, world!, es -> ¡Hola, mundo!))
Left(NotFound(no message for lang=fr))