Skip to content

Instantly share code, notes, and snippets.

@gakuzzzz
Last active August 10, 2021 08:50
Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gakuzzzz/147c520e32177fea75f0 to your computer and use it in GitHub Desktop.
Save gakuzzzz/147c520e32177fea75f0 to your computer and use it in GitHub Desktop.
Free-ScalikeJDBC から見る合成可能なDSLの作り方

Free-ScalikeJDBC から見る合成可能なDSLの作り方

2015/07/24 関数型Scalaの集い

自己紹介

ScalikeJDBCとは

http://scalikejdbc.org/

scalikejdbc

  • JDBC を Scala から使いやすくするためのライブラリ
  • なるべくDRYかつミスを少なくSQLを書けるように
val (p, c) = (Programmer.syntax("p"), Company.syntax("c"))

val programmers: Seq[Programmer] = DB.readOnly { implicit session =>
  withSQL {
    select
      .from(Programmer as p)
      .leftJoin(Company as c).on(p.companyId, c.id)
      .where.eq(p.isDeleted, false)
      .orderBy(p.createdAt)
      .limit(10)
      .offset(0)
  }.map(Programmer(p, c)).list.apply()
}

その他のDBライブラリ

Slick3

http://slick.typesafe.com/

  • RDBをコレクションのように見なして操作できるライブラリ
val a: DBIOAction[Unit] = for {
  ns <- coffees.filter(_.name.startsWith("ESPRESSO")).map(_.name).result
  _  <- ns.traverse(n => coffees.filter(_.name === n).delete.result)
} yield ()

val f: Future[Unit] = db.transactionally.run(a)

モナド!!!

DBIOAction

doobie

https://github.com/tpolecat/doobie

  • doobie is a pure functional JDBC layer for Scala. It is not an ORM, nor is it a relational algebra
  • it just provides a principled way to construct programs (and higher-level libraries) that use JDBC
  case class CountryCode(code: String)
  
  def main(args: Array[String]): Unit = 
    tmain.trans[IO].unsafePerformIO

  val tmain: DM.DriverManagerIO[Unit] = 
    for {
      _ <- DM.delay(Class.forName("org.h2.Driver"))
      c <- DM.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "sa", "")
      a <- DM.liftConnection(c, examples ensuring C.close).except(t => t.toString.point[DM.DriverManagerIO])
      _ <- DM.delay(Console.println(a))
    } yield ()

  def examples: C.ConnectionIO[String] =
    for {
      _ <- C.delay(println("Loading database..."))
      _ <- loadDatabase(new File("example/world.sql"))
      s <- speakerQuery("English", 10)
      _ <- s.traverseU(a => C.delay(println(a)))
    } yield "Ok"

  def loadDatabase(f: File): C.ConnectionIO[Unit] =
    for {
      ps <- C.prepareStatement("RUNSCRIPT FROM ? CHARSET 'UTF-8'")
      _  <- C.liftPreparedStatement(ps, (PS.setString(1, f.getName) >> PS.execute) ensuring PS.close)
    } yield ()

  def speakerQuery(s: String, p: Double): C.ConnectionIO[List[CountryCode]] =
    for {
      ps <- C.prepareStatement("SELECT COUNTRYCODE FROM COUNTRYLANGUAGE WHERE LANGUAGE = ? AND PERCENTAGE > ?")
      l  <- C.liftPreparedStatement(ps, speakerPS(s, p) ensuring PS.close)
    } yield l

  def speakerPS(s: String, p: Double): PS.PreparedStatementIO[List[CountryCode]] =
    for {
      _  <- PS.setString(1, s)
      _  <- PS.setDouble(2, p)
      rs <- PS.executeQuery
      l  <- PS.liftResultSet(rs, unroll(RS.getString(1).map(CountryCode(_))) ensuring RS.close)
    } yield l

モナド!!!!

ある日の ScalikeJDBC の Gitter

Do you guys have something like a DB monad that I can use?

モナド!!!!!

DBライブラリはなぜモナドにしたがるのか

状態管理

基本的に副作用があり状態も管理する必要がある

  • Connection
  • Transaction

合成可能性

手続き的トランザクションでは合成できない

  // 擬似コードなので動きません

  def logicA(entity: EntityA): Unit = {
    val tx = Transaction.begin()
    try {
      insert(entity)
      tx.commit()
    } catch {
      e: Throwable => tx.rollback()
    }
  }

  def logicB(entity: EntityB): Unit = {
    val tx = Transaction.begin()
    try {
      insert(entity)
      tx.commit()
    } catch {
      e: Throwable => tx.rollback()
    }
  }


  def logicC(): Unit = {
    logicA と logicB を同一トランザクションで呼びたい
  }

ScalikeJDBC は合成については implicit parameter で実現しています

  // 擬似コードなので動きません

  def logicA(entity: EntityA)(implicit session: DBSession = AutoSession): Unit = {
    insert(entity)
  }

  def logicB(entity: EntityB)(implicit session: DBSession = AutoSession): Unit = {
    insert(entity)
  }


  def logicC(): Unit = {
    DB.localTx { implicit s =>
      logicA(a)
      logicB(b)
    }
  }

実用上、これで困ることはないです。

けどなんか悔しいので ScalikeJDBC でもモナモナしたい!!!

free-scalikejdbc

というわけで作りました。

https://github.com/gakuzzzz/free-scalikejdbc

  def createProgrammer[F[_]](name: Name, skillIds: List[SkillId])(implicit S: ScalikeJDBC[F], M: Applicative[FreeC[F, ?]]) = {
    import S._
    for {
      id     <- generateKey(insert.into(Programmer).namedValues(pc.name -> name))
      skills <- list(select.from(Skill as s).where.in(s.id, skillIds))(Skill(s))
      _      <- skills.traverse[FreeC[F, ?], Boolean](s => execute(insert.into(ProgrammerSkill).namedValues(sc.programmerId -> id, sc.skillId -> s.id)))
    } yield Programmer(id, name, skills)
  }

  val newProgrammer = DB.localTx {
    Interpreter.transaction.run(createProgrammer("Alice", List(2, 3)))
  }

Free モナド

  • Free モナドとは、Functor を ベースに Monad を作れる構造のこと
  • 合成可能なDSLを作りたい時に使われる

日本語でおk

たぶん、文章で説明してもすでに理解できてる人にしか理解できない怪文章になってしまうので、具体的にコードで

Functor とか Monad とかは知ってるものとして話を進めるので、怪しい人は去年のScalaz勉強会の資料 主要な型クラスの紹介 もご参照ください

Free モナドを使った DSL の作り方

1. まず合成したい単位を表す型を定義します

sealed abstract class Query

2. これだけでは値を返せないしFreeにもできないので、型引数を足します

sealed abstract class Query[A]

3. 作成した型の実装としてDSLの構文木を表すクラスorオブジェクトを定義します

sealed abstract class Query[A]
object Query {
  case class GetList[A] extends Query[List[A]]
  case class GetOption[A] extends Query[Option[A]]
  case object Execute extends Query[Boolean]
}

4. DSLの構文としてFreeを返すメソッドを定義します

ここでは Scalaz7.1.x を使います。

sealed class ScalikeJDBC[F[_]] {

  def list[A](sql: SQLBuilder[_])(f: WrappedResultSet => A): FreeC[F, List[A]] = ???
  def first[A](sql: SQLBuilder[_])(f: WrappedResultSet => A): FreeC[F, Option[A]] = ???
  def execute(sql: SQLBuilder[UpdateOperation]): FreeC[F, Boolean] = ???

}
object ScalikeJDBC {
  implicit def instance[F[_]]: ScalikeJDBC[F] = new ScalikeJDBC[F]
}

Free は Functor を使って Monad にする構造です。しかし今定義した Query[A] は特に Functor を定義していません。

そこで、Coyoneda を使って、ただの * -> * kind の型である Query[A] を Functor にします。

FreeC はこの Coyoneda を使ってラップした Free のことです。

type FreeC[S[_], A] = Free[Coyoneda[S, ?], A]

Coyoneda を使って Free を作るアイデアは非常に便利で Operational Monad という名前でも知られています。

5. 現状だと、構文のメソッドが SQLBuilder を受け取っているけど Query[A] がそれを使えないので、Query[A] にパラメータを足します

sealed abstract class Query[A](private[free] val statement: String, private[free] val parameters: Seq[Any])
object Query {
  case class GetList[A](sql: SQLToList[A, HasExtractor]) extends Query[List[A]](sql.statement, sql.parameters)
  case class GetOption[A](sql: SQLToOption[A, HasExtractor]) extends Query[Option[A]](sql.statement, sql.parameters)
  case class Execute(sql: SQLExecution) extends Query[Boolean](sql.statement, sql.parameters)
}

6. 改良した Query[A] を使って、構文のメソッドを実装していきます

sealed class ScalikeJDBC[F[_]](implicit I: Inject[Query, F]) {
  private def lift[A](v: Query[A]): FreeC[F, A] = Free.liftFC(I.inj(v))
  def list[A](sql: SQLBuilder[_])(f: WrappedResultSet => A): FreeC[F, List[A]] = {
    val q = withSQL(sql).map(f).list()
    lift(GetList[A](q.statement, q.parameters))
  }
  def first[A](sql: SQLBuilder[_])(f: WrappedResultSet => A): FreeC[F, Option[A]] = {
    val q = withSQL(sql).map(f).first()
    lift(GetOption[A](q.statement, q.parameters))
  }
  def execute(sql: SQLBuilder[UpdateOperation]): FreeC[F, Boolean] = {
    val q = withSQL(sql).execute()
    lift(Execute(q.statement, q.parameters))
  }
}
object ScalikeJDBC {
  implicit def instance[F[_]](implicit I: Inject[Query, F]): ScalikeJDBC[F] = new ScalikeJDBC[F]
}

突然の Inject[Query, F] !!!!

実は Free で DSL を作る、というだけの範囲では Inject を使う必要はありません。

Free-ScalikeJDBC では Coproduct を使って、他の Free モナドを利用した DSL と合成ができるようにするため、ここで Inject を使用しています。

Coproduct を利用した Free モナドの合成については吉田さんの記事 が日本語でわかりやすいので参考まで。

7. DSL完成!

実は以上でDSLとしては完成です。

以下のようにfor式で各クエリを合成して Query[A] を取得するコードを書くことができます。

  private lazy val a = Account.syntax("a")
  private lazy val ac = Account.column

  def create[F[_]](name: String)(implicit S: ScalikeJDBC[F]) = {
    import S._
    for {
      account <- first(select.from(Account as a).where.eq(a.id, id))(Account(a))
      _       <- S.execute(insert.into(Account).namedValues(ac.name -> account.name + " 2nd"))
    } yield account
  }

でも Query[A] インスタンスがあるだけでは何もできないので困ってしまいます。

8. Interpreter を実装します

Interpreter ってかっこよく言ってますが、実際のところ、Query[A] から何らかの M[A] の値を取り出す只の関数のことです。

つまり Query ~> M の事ですね。

Query ~> MNaturalTransformation[Query, M] のことです。

abstract class Interpreter[M[_]](implicit M: Monad[M]) extends (Query ~> M) {

  protected def exec[A](f: DBSession => A): M[A]

  def apply[A](c: Query[A]): M[A] = c match {
    case GetList(sql)       => exec(implicit s => sql.apply())
    case GetOption(sql)     => exec(implicit s => sql.apply())
    case Execute(sql)       => exec(implicit s => sql.apply())
  }

  def run[A](q: FreeC[Query, A]): M[A] = Free.runFC(q)(this)

}
object Interpreter {

  lazy val auto = new Interpreter[Id] {
    protected def exec[A](f: DBSession => A) = f(AutoSession)
  }

  type SQLEither[A] = SQLException \/ A
  object SQLEither {
    implicit def TxBoundary[A] = new TxBoundary[SQLEither[A]] {
      def finishTx(result: SQLEither[A], tx: Tx) = {
        result match {
          case \/-(_) => tx.commit()
          case -\/(_) => tx.rollback()
        }
        result
      }
    }
  }
  lazy val safe = new Interpreter[SQLEither] {
    protected def exec[A](f: DBSession => A) = \/.fromTryCatchThrowable[A, SQLException](f(AutoSession))
  }

  type TxExecutor[A] = Reader[DBSession, A]
  lazy val transaction = new Interpreter[TxExecutor] {
    protected def exec[A](f: DBSession => A) = Reader.apply(f)
  }

  type SafeExecutor[A] = ReaderT[SQLEither, DBSession, A]
  lazy val safeTransaction = new Interpreter[SafeExecutor] {
    protected def exec[A](f: DBSession => A) = {
      Kleisli.kleisliU { s: DBSession => \/.fromTryCatchThrowable[A, SQLException](f(s)) }
    }
  }

}

ここではいろんな M を返す Interpreter を作れるように、abstract class で共通処理をまとめています。

FreeでのDSLの作り方まとめ

  1. * -> * kind の型をつくる
  2. 1.で作った型をFreeでラップした値を返すメソッドをつくる
  3. Interpreter つくる

簡単ですね!!!

Free-ScalikeJDBC の今後の野望

Slick3 が reactive-streams API に対応してるぜ! ってドヤ顔してるので、Free-ScalikeJDBC も scalaz-stream 使用したAPIを提供して、streamz 経由で reactive-streams API に対応してるぜ!ってドヤ顔しかえしたい(PR募集中)

@KisaragiEffective
Copy link

非常に些細なことですが、

    • -> * kind の型をつくる

というのは

  1. * -> * kind の型をつくる

ということでしょうか?

@gakuzzzz
Copy link
Author

あ、その通りです。ご指摘ありがとうございます。 gist の Markdown の解釈が変わった影響で意図しない形にrenderingされたようですね。修正しておきます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment