Imaginemos que queremos integrar una nueva base de datos en nuestra codebase, y digamos que además queremos hacerlo manteniendo la armonía de nuestro código: cualquier uso de la nueva base de datos deberá reutilizar los mismos patrones que ya estamos usando, parecerse lo más posible al resto del código de nuestra codebase.

Por ejemplo, si ya estamos usando un sistema de efectos basado en la mónada IO, una consulta que devuelva un listado de elementos debería poder hacerse de la siguiente forma:

def filmsByRating: IO[List[RatingCounter]] =
  val sql = "SELECT rating, count(*) AS count FROM film GROUP BY rating"
  transactor.query(sql).list[RatingCounter]

Yo he elegido la palabra transactor por similitud con doobie, una capa JDBC funcional para Scala, pero en este contexto se puede entender como un cliente o conector a una base de datos externa a nuestro código, y que proporciona todas las operaciones necesarias como query y update:

trait Transactor:
  def update(expression: String): IO[Unit]
  def query(expression: String): IoQuery

Internamente, el transactor va a mantener una (o varias) conexiones abiertas, y las va a utilizar para enviar todas nuestras operaciones a la base de datos.

Update#

En el caso de la operación de update no vamos a necesitar ninguna respuesta, así que vamos a descartar toda la información que nos pueda devolver la base de datos:

  override def update(expression: String): IO[Unit] =
    connection.query[Any](expression).toIO.flatMap(_ => IO.unit)

Ahora, digamos que el conector nativo de la base de datos utiliza un sistema de efectos diferente al que estamos usando en nuestra codebase. Por ejemplo, digamos que la operación query del conector nativo devuelve una promesa de JavaScript como en el siguiente ejemplo:

  // native database conector
  def query[A](query: String): js.Promise[A]

Siguiendo la premisa inicial de este artículo, vamos a necesitar una forma de automatizar la transformación del sistema de efectos que utiliza el conector nativo de la base de datos en la IO que utilizamos en nuestra codebase.

Hay muchas abstracciones que podrían ocultar esta transformación, incluyendo la companion type class, que es la que yo voy a utilizar en este artículo. Precisamente, la función toIO que hace este trabajo en el ejemplo inicial de este artículo se puede definir de la siguiente forma:

object PromiseSyntax:
  extension [A](self: js.Promise[A])
    def toIO(using executionContext: ExecutionContext): IO[A] = for
      blockingIO <- IO.blocking(self.toFuture.andThen { 
        case Success(result) => result 
      })
      response <- IO.fromFuture(blockingIO)

Query#

La operación de query va a necesitar un poco más de trabajo porque a diferencia del update, en la query vamos a devolver una respuesta. Digamos que el conector nativo de la base de datos devuelve un Array de JavaScript, donde cada posición del Array contiene un objeto con la información de una entidad:

  override def query(expression: String): IoQuery = IoQuery(
    connection
      .query[js.Array[js.Object]](expression)
      .toIO,
  )

Por ejemplo, en una base de datos relacional, cada posición del Array representaría una fila, y los atributos del objeto serían las columnas seleccionadas en la query.

Una de las primeras diferencias que notamos con la operación de update es que el tipo de resultado de la query es un tipo propio IoQuery, definido de la siguiente forma:

final case class IoQuery(query: IO[js.Array[js.Object]])

La principal razón para definir el tipo IoQuery es porque nos permite extender fácilmente el comportamiento de nuestra query con transformaciones muy ergonómicas, como puede apreciarse en el siguiente ejemplo:

object IoQuery:
  extension (self: IoQuery)
    def list[A](using rowMapper: RowMapper[A]): IO[List[A]] = 
      self.query.map(rowMapper.from)

    def nonEmptyList[A](using rowMapper: RowMapper[A]): IO[NonEmptyList[A]] =
      list[A].flatMap(
        NonEmptyList
          .fromList(_)
          .fold(IO.raiseError[NonEmptyList[A]](
            new IllegalArgumentException("No records found"))
          )(IO.pure),
      )

    def option[A](using rowMapper: RowMapper[A]): IO[Option[A]] = list[A].flatMap {
      case ::(head, Nil) => IO.some(head)
      case Nil => IO.none
      case _ => IO.raiseError(
        new IllegalArgumentException("Multiple records found, one expected"))
    }

La primera transformación es la que vimos en el ejemplo inicial de este artículo: list[A] transforma el Array de objetos que devuelve el conector nativo de la base de datos en una lista de elementos de tipo A.

A su vez, la transformación list[A] se puede aprovechar para definir otras transformaciones como por ejemplo: nonEmptyList[A] que asegura que la respuesta contiene algún elemento (no es una lista vacía), y option[A] que garantiza que la respuesta contiene uno o ningún elemento.

Row Mapping#

La parte más interesante todas estas transformaciones consiste en que no necesitan de ningún parser para extraer el valor de cada columna y asignarlo a una case class.

Volviendo al ejemplo inicial, nuestra query contaba el número de películas de la base de datos, agrupadas por su clasificación:

SELECT rating, count(*) AS count FROM film GROUP BY rating
RATING COUNT
PG-13 41
NC-17 1
R 118
PG 213

La transformación list[A] transforma cada una de las filas de esta tabla en la case class RatingCounter, en la que los nombres de sus campos coinciden con las columnas de la tabla (sin importar las mayúsculas y minúsculas):

final case class RatingCounter(rating: Rating, count: Int)

Para ello, las transformaciones van a usar una row mapper que se genera automáticamente para todos los campos de la case class cuyos tipos estén soportados: Date, Double, Integer, NonEmptyString y String.

La enumeraciones son un caso especial que necesitan de un hint que le digan a la row mapper cómo transformar el valor almacenado en la base de datos en uno de los elementos de la enumeración.

En sí misma, la row mapper no es más que un un trait que define la operación from para transformar de un Array de objetos de JavaScript a una lista de A:

trait RowMapper[A]:
  def from(rows: js.Array[js.Object]): List[A]

Sin embargo, la implementación es algo más compleja porque utiliza dos conceptos que fueron reintroducidos con Scala 3: la meta programación y la programación genérica.

La implementación de RowMapper es la siguiente:

object RowMapper:
  inline given apply[A](using
      databaseType: DatabaseType[A],
      databaseTypeHints: DatabaseTypeHints,
      mirror: Mirror.ProductOf[A],
  ): RowMapper[A] = (rows: js.Array[js.Object]) =>
    val fields = databaseType.columns.map { (columnName, columnType) =>
      CIString(columnName) -> columnType
    }.reverse
    rows.map { row =>
      val rowMap = js.Object.entries(row).map(t => (CIString(t._1), t._2)).toMap
      @tailrec
      def fillWith(fields: List[(CIString, String)], values: Tuple): Tuple = fields match
        case Nil => values
        case ::((columnName, columnType), next) =>
          fillWith(
            next,
            rowMap
              .get(columnName)
              .map { value =>
                (ColumnType.valueOf(columnType) match
                  case DateType =>
                    val jsDate = value.asInstanceOf[js.Date]
                    LocalDate
                      .of(
                        jsDate.getFullYear().toInt,
                        jsDate.getMonth().toInt + 1,
                        jsDate.getDate().toInt,
                      )
                  case EnumType =>
                    val jsString = value.asInstanceOf[String]
                    databaseTypeHints
                      .hintFor(columnName)
                      .map(_.apply(jsString))
                      .getOrElse(
                        throw IllegalStateException(s"No hint found for column: $columnName"),
                      )
                  case NonEmptyStringType =>
                    val jsString = value.asInstanceOf[String]
                    NonEmptyString
                      .fromString(jsString)
                      .getOrElse(
                        throw IllegalStateException(s"Unsupported value for column: $columnName"),
                      )
                  case DoubleType | IntType | StringType => value
                ) *: values
              }
              .getOrElse(throw new IllegalStateException(s"Missing field: $columnName")),
          )
      val values = fillWith(fields, EmptyTuple)
      summon[Mirror.ProductOf[A]].fromProduct(values)
    }.toList

El primer parámetro del smart constructor de RowMapper es de tipo DatabaseType. Esta case class utiliza Quotes para extraer en tiempo de compilación el nombre y el tipo de cada uno de los campos del tipo concreto de A, y los almacena en una lista:

final case class DatabaseType[A](columns: List[(String, String)])

El tipo Quotes es parte del rediseño de las capacidades de meta programación en Scala 3.

Como parte de la implementación de DatabaseType se comprueba que A es una case class. Esta validación produce un error en tiempo de compilación:

  if !(symbol.isClassDef && symbol.flags.is(Flags.Case)) then
    report.errorAndAbort(s"Expecting a case class, but got: ${summon[Type[A]]}")

El segundo parámetro de RowMapper es de tipo DatabaseTypeHints, y contiene los hints que solamente son necesarios para transformar el valor de una enumaración:

final case class DatabaseTypeHints(hints: Map[CIString, String => Any]):
  def hintFor(column: CIString): Option[String => Any] = hints.get(column)

object DatabaseTypeHints:
  given DatabaseTypeHints = DatabaseTypeHints(Map.empty)

Por ejemplo, la query que hemos estado utilizando en este artículo incluye el tipo Rating que define la clasificación de una película como sigue:

// Motion Picture Association film rating
enum Rating(val name: String):
  case G extends Rating("G")
  case PG extends Rating("PG")
  case PG_13 extends Rating("PG-13")
  case R extends Rating("R")
  case NC_17 extends Rating("NC-17")

object Rating:
  private def fromName(name: String): Option[Rating] = 
    Rating.values.find(_.name == name)

  def fromNameOrFail(name: String): Rating = fromName(name).getOrElse(
    throw new IllegalArgumentException(s"No rating found with name: $name"),
  )

Como se puede ver en la tabla de ejemplo al principio de esta sección, los valores almacenados en la base de datos utilizan el valor del campo name de la enumeración (PG-13) y no su tipo (PG_13). El siguiente hint permite a la row mapper transformar el texto de la base de datos en el elemento correspondiente de la enumeración:

  given DatabaseTypeHints = DatabaseTypeHints(
    Map((ci"rating", (rating: String) => Rating.fromNameOrFail(rating))),
  )

Por último, el tercer parámetro de RowMapper es de tipo Mirror.ProductOf[A]. Este tipo permite hacer programación genérica, y se incluye dentro de las nuevas abstracciones contextuales que permiten derivar type classes automáticamente en Scala 3, de manera semejante a como se podía hacer en Scala 2 utilizando shapeless.

En RowMapper está utilizado para almacenar el resultado de las transformaciones en una tupla, a medida que estas se van produciendo para cada una de las columnas de la base de datos. Una vez que se han completado todas las transformaciones, se crea una instancia del tipo A con los valores almacenados en la tupla:

  summon[Mirror.ProductOf[A]].fromProduct(values)

Resumen y Conclusiones#

En este artículo he descrito brevemente el diseño de un conector de bases de datos utilizando un sistema de efectos basado en la mónada IO, que abstrae algunos detalles como la transformación del sistema de efectos del conector nativo y la transformación de los tipos de la base de datos a tipos de Scala.

El conector está desarrollado de tal manera que permite centrarse únicamente en los detalles de la lógica de negocio, disminuyendo considerablemente el ruido de los detalles técnicos.

El objetivo principal es que el conector proporcione la mejor experiencia de desarrollador posible. Esta estrategia debería permitirnos que la lógica de negocio sea la protagonista de nuestra code base.

Por ejemplo, la siquiente consulta calcula los ingresos acumulados para un rango de días:

    override def cumulativeRevenueDuring(
        dateTimeRange: DateRange[LocalDateTime],
    ): IO[List[CumulativeRevenue]] =
      val (fromDate, toDate) = dateTimeRange.format(ISO_LOCAL_DATE)
      val sql =
        s"""SELECT 
           |   payment_date AS paymentDate, 
           |   amount, 
           |   sum(amount) OVER (ORDER BY payment_date) AS cumulativeRevenue
           | FROM (
           |   SELECT 
           |      CAST(payment_date AS DATE) AS payment_date, 
           |      SUM(amount) AS amount
           |   FROM payment
           |   WHERE payment_date BETWEEN '$fromDate' AND '$toDate'
           |   GROUP BY CAST(payment_date AS DATE)
           | ) p
           | ORDER BY payment_date
           |""".stripMargin
      transactor.query(sql).list[CumulativeRevenue]

La mayor parte del código es la consulta SQL que se ejecutará en la base de datos, y el boilerplate es mínimo.

Las aplicaciones reales de este tipo de diseño son muchas. Sin ir más lejos, el código de este artículo es un caso real de integración de un driver de Node.js para MySQL que está escrito en JavaScript. Este driver se integra como dependencia en un proyecto de Scala.js:

  Compile / npmDependencies ++= Seq(
    "mysql" -> "2.18.1",
    "promise-mysql" -> "5.2.0",
  ),

Las consultas SQL utilizadas en el artículo forman parte de la base de datos Sakila, un demostrador de MySQL.

El código completo del ejemplo está en este repositorio de GitHub.

unofficial shapeless logo