Definimos FreeMonad como aquella construcción que permite construir una mónada desde un functor. En la presente entrada, FreeMonad en Cats, describiré cómo definir y utilizar una FreeMonad mediante un ejemplo.
Según Wikipedia, definimos funtor como sigue:
«En teoría de categorías un funtor o functor es una función de una categoría a otra que lleva objetos a objetos y morfismos a morfismos de manera que la composición de morfismos y las identidades se preserven».
Un functor cumple la propiedad de identidad y de composición.
Según Wikipedia, definimos mónada como sigue:
«En la programación funcional , una mónada (monad en inglés), es una estructura que representa cálculos definidos como una secuencia de pasos»
Una mónada cumple la propiedad de identidad y asociativa.
La FreeMonad permite lo siguiente:
- Representar cálculos con estado como datos y ejecutarlos.
- Ejecutar cálculos recursivos.
- Definir un DSL.
- Reorientar un cálculo a otro intérprete utilizando transformaciones naturales.
La implementación de FreeMonad que utilizaremos en el ejercicio es aquella que se ha definido en la librería Cats y, el módulo de cats empleado, es el paquete que se define en cats-free. Una FreeMonad se define como Free[_]
La definición de dependencias del módulo sbt en el fichero build.sbt es la siguiente:
lazy val catsEffect = (project in file("cats-effect")) .settings( name := "example-cats-effect", assemblySettings, scalacOptions ++= basicScalacOptions, libraryDependencies ++= catsEffectDependencies ++ Seq( scalaTest ) ) lazy val catsEffectDependencies = Seq( cats_effect, cats_effect_laws )
La definición de las coordenadas de los paquetes a utilizar en el módulo para la librería cats-free son las siguientes:
lazy val cats_effect_laws = "org.typelevel" %% "cats-effect-laws" % "2.2.0" % "test" lazy val cats_free = "org.typelevel" %% "cats-free" % "2.2.0"
Presentación del problema
Supongamos que tenemos un aplicativo el cual maneja una entidad Autor y necesitamos persistir los datos de la entidad Autor en base de datos. Para ello, necesitamos realizar operaciones sobre base de datos como son: creación del esquema, insertar, borrar y buscar elementos. La definición de la entidad de dominio del problema se define en la siguiente case class:
case class Author(id:Long, name: String)
Para dar solución a este problema, definiremos un DSL que soporte las funcionalidades requeridas. Evidentemente, es un problema teórico con el objetivo describir la definición de una FreeMonad.
Definición de la gramática
La gramática del DSL queda definida en un ADT (Tipo de dato Algebraico) el cual define las operaciones a realizar. El ADT con las operaciones de base de datos OperationDB[A] queda definido en la siguiente estructura de clases:
sealed trait OperationDB[A] case class CreateSchema() extends OperationDB[OperationDBResponse[Boolean]] case class Insert(author: Author) extends OperationDB[OperationDBResponse[Int]] case class Select(key: Int) extends OperationDB[OperationDBResponseOption[String]] case class Delete(key: Int) extends OperationDB[OperationDBResponse[Int]]
Las operaciones de base de datos son: CreateSchema, operación para la creación del esquema de la entidad Autor; Insert, operación de inserción de un Autor; Select, búsqueda de un Autor en función de una clave; y, Delete, eliminación de un Autor por clave. El tipo parametrizado es el tipo de retorno de cada operación cuyos tipos son los representados en los siguiente alias:
type OperationDBResponse[A] = Either[Exception, A] type OperationDBResponseOption[A] = Either[Exception, Option[A]]
Los alias descritos anteriormente define contenedores binarios de Tipo Either cuyo valor Left representa una excepción y, su valor Right, representa el resultado de la operación de tipo A u Option[A]
Definición del DSL
El DSL se define mediante una FreeMonad la cual, utilizando Cats, queda definida con el tipo Free[_]. Así, el alias del tipo de operación del DSL se define utilizando el siguiente alias:
type Operation[A] = Free[OperationDB, A]
El alias Operation[A] define una FreeMonad cuyo valor de entrada es una operación de tipo OperationDB y cuyo resultado es un tipo genérico A.
El DSL debe de definir funciones para las siguientes operaciones: creación de un esquema, inserción, borrado y consulta. Las funciones del DSL son las siguientes:
import cats.free.Free import cats.free.Free.liftF [...] def createSchema(): Operation[OperationDBResponse[Boolean]] = liftF[OperationDB, OperationDBResponse[Boolean]](CreateSchema()) def insert(elem: Author): Operation[OperationDBResponse[Int]] = liftF[OperationDB, OperationDBResponse[Int]](Insert(elem)) def delete(key: Int): Operation[OperationDBResponse[Int]] = liftF[OperationDB, OperationDBResponse[Int]](Delete(key)) def select(key: Int): Operation[OperationDBResponseOption[String]] = liftF[OperationDB, OperationDBResponseOption[String]](Select(key))
Las funciones del DSL utilizan la función liftF que permite subir de contexto de una operación OperationDB con un tipo de respuesta a una FreeMonad, Free[_].
Definición del intérprete del DSL
Una vez definido el lenguaje de dominio es necesario definir aquel intérprete que sea capaz de traducir el lenguaje en operaciones de base de datos. En nuestro caso, utilizaremos una entidad repositorio el cual sea capaz de realizar las operaciones de base de datos, consiguiendo desacoplar las operaciones de interpretación del lenguaje con las operaciones directas de base de datos.
El repositorio es un objeto llamado AuthorRepository el cual utiliza la librería funcional Doobie para realizar las operaciones.
Para cada operación del lenguaje, el intérprete gestiona un estado. Los estados son los siguientes: Init, estado inicial que representa el estado previo a la creación del esquema; Created, estado que indica que el esquema ha sido creado; Deleted, estado que representa la eliminación del esquema, en la práctica no se utiliza.
Los estados quedan representados en la siguiente estructura:
sealed trait StateDatabase case object Init extends StateDatabase case object Created extends StateDatabase case object Deleted extends StateDatabase
Dada la existencia de estados, el intérprete utilizará la mónada estado para gestionar el retorno de estado de cada operación.
El intérprete define un functor de transformación, representado por el símbolo ~> el cual transforma entidades de tipo OpearetionDB en OperationState. El functor intérprete recibe como parámetro un Transactor de Doobie de la base de datos con la que se opera. El snippet de código del intérprete es el siguiente:
import cats.~> [...] def pureInterpreter(xa: Aux[IO, Unit]): OperationDB ~> OperationState = new(OperationDB ~> OperationState){ override def apply[A](fa: OperationDB[A]): OperationState[A] = fa match { case CreateSchema() => { val resultCreateSchema: Either[Exception, Boolean] = AuthorRepository.createSchemaIntoMySqlB(xa) resultCreateSchema .fold( ex => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Init, resultCreateSchema) }, value => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Created, resultCreateSchema) } ) } case Insert(author) =>{ State[StateDatabase, OperationDBResponse[Int]]{ state => (Created, AuthorRepository.insertAuthorIntoMySql(xa, author)) } } case Delete(key) => { State[StateDatabase, OperationDBResponse[Int]]{ state => (Created, AuthorRepository.deleteAuthorById(xa, key)) } } case Select(key) => { State[StateDatabase, OperationDBResponseOption[String]]{ state => (Created, AuthorRepository.selectAuthorById(xa, key)) } } } }
Definición del repositorio de la entidad Autor.
El repositorio con las operaciones de base de datos está definiado en un objeto AuthorRepository que utiliza la librería Doobie. El objeto define un conjunto de funciones con las operaciones que utiliza el intérprete del DSL; como ejemplo, la operación de creación del esquema es la siguiente:
val dropAuthor: ConnectionIO[Int] = sql"""DROP TABLE IF EXISTS Author""".update.run val createAuthor: ConnectionIO[Int] = sql"""CREATE TABLE Author (id SERIAL, name text)""".update.run def createSchemaIntoMySqlA[A](xa: Aux[IO, Unit]): Either[Exception, Unit] = { val creator: ConnectionIO[Unit] = for { _ <- dropAuthor _ <- createAuthor } yield () try{ val resultDatabase: Unit = creator.transact(xa).unsafeRunSync Right(resultDatabase) } catch { case e: java.sql.SQLException =>{ println(s"Error create schema: ${e}") Left(e) } } }
Para el lector interesado en el código del repositorio puede acceder en el siguiente enlace.
Definición de los programas
Una vez definida en un objeto AuthorDSL las funciones del DSL, alias e intérpretes, estamos en disposición de definir las declaraciones de los programas que pueden utilizar dicho lenguaje; para ello, necesitamos importar el DSL y definir el Transactor de base de datos, estas operaciones, se realizan de la siguiente forma:
import doobie.Transactor import doobie.util.ExecutionContexts import es.ams.freemonaddoobie.AuthorDSL._ [...] implicit val cs = IO.contextShift(ExecutionContexts.synchronous) val xa = Transactor.fromDriverManager[IO]( "com.mysql.jdbc.Driver", "jdbc:mysql://localhost:3306/doobie", "root", "root", Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing )
Para la correcta ejecución del programa es necesario tener una base de datos levantada en la configuración que se especifica en el Transactor; en mi caso, he utilizado un contenedor Docker con una base de datos MySQL.
La definición de un programa está compuesta por la secuencia de funciones del DSL definidas en un for comprehension y, para su ejecución, se utiliza la función foldMap con el intérprete definido. Al utilizar la mónada estado, es necesario arrancar la ejecución con un estado.
En los siguientes apartados, se describen ejemplos de las declaraciones de programas del DSL definido
1.- Ejemplo de programa de creación de un esquema.
def createDatabase(): Operation[Either[Exception, Boolean]] = for { result <- createSchema() } yield (result) val resultCreate = createDatabase().foldMap(pureInterpreter(xa)).run(Init).value println(s"Create database=${resultCreate}") println
La salida por consola es la siguiente:
Create database=(Created,Right(true))
2.- Ejemplo de programa de inserción de un Autor en base de datos.
def insertAuthor(): Operation[Either[Exception, Int]] = for { num <- insert(Author(0, "Author1")) } yield (num) val resultInsertAuthor = insertAuthor().foldMap(pureInterpreter(xa)).run(Created).value println(s"Insert Author=${resultInsertAuthor}") Println
La salida por consola es la siguiente:
Insert Author=(Created,Right(1))
3.- Ejemplo de programa de eliminación de un Autor en base de datos.
def deleteAuthor(): Operation[Either[Exception, Int]] = for { numInsert10 <- insert(Author(0, "Author10")) numInsert11 <- insert(Author(0, "Author11")) numInsert12 <- insert(Author(0, "Author12")) numDeleted <- delete(2) } yield (numDeleted) val resultDeleteAuthor = deleteAuthor().foldMap(pureInterpreter(xa)).run(Created).value println(s"Delete Author=${resultDeleteAuthor}") println
La salida por consola es la siguiente:
Delete Author=(Created,Right(1))
4.- Ejemplo de programa de consulta de un autor por clave.
def selectAuthorOK(): Operation[Either[Exception, Option[String]]] = for { author <- select(1) } yield (author) val resultSelectAuthorOK = selectAuthorOK.foldMap(pureInterpreter(xa)).run(Created).value println(s"Select Author=${resultSelectAuthorOK}") println
La salida por consola es la siguiente:
Select Author=(Created,Right(Some(Author1)))
Para el lector interesado puede acceder al código de la entrada en el siguiente enlace.