Quiero modelar el catálogo de una tienda de ropa. Empezaré con algo muy simple: cada prenda de ropa está identificada por un número entero, y de cada prenda se conoce su modelo, el precio, una descripción y la fecha con la que salió por primera vez a la venta.

1
2
3
4
5
6
7
case class Garment(
  id: Long,
  model: String,
  price: Double,
  description: String,
  launchDate: LocalDate,
)

Esta información se puede representar con la siguiente tabla en una base de datos relacional:

1
2
3
4
5
6
7
8
CREATE TABLE garments (
  id BIGINT NOT NULL PRIMARY KEY,
  model TEXT NOT NULL,
  price NUMERIC(9,5) NOT NULL
    CHECK (price > 0),
  description TEXT NOT NULL,
  launch_date DATE NOT NULL
);

Como la correspondencia entre el modelo de negocio y el relacional es directa no necesito pasar por una representación intermedia. Por ejemplo, el siguiente código usa doobie para recuparar una prenda de la base de datos mediante su identificador:

1
2
3
4
5
6
7
class GarmentRepository(transactor: Transactor[IO]):
  def findGarmentById(id: Long): IO[Option[Garment]] =
    val sql =
      sql"""SELECT id, model, price, description, launch_date
           |FROM garments
           |WHERE id = $id""".stripMargin
    sql.query[Garment].option.transact(transactor)

En el mismo fragmento de código se mezcla la consulta escrita en SQL con la respuesta que utiliza la clase Garment de mi modelo de dominio.

Primer cambio: diferentes monedas#

Ahora quiero mejorar la experiencia de usuaria, y para eso voy a modificar el catálogo para que cada usuaria pueda ver los precios en la moneda con la que está más familiarizada.

Para este cambio me voy a apoyar en Squants, un sistema que permite operar con cantidades y sus unidades de medida. Voy a cambiar el modelo para representar el precio como Money, un tipo de cantidad dimensional cuyas unidades de medida son las monedas:

1
2
3
4
5
6
7
case class Garment(
  id: Long,
  model: String,
  price: Money,
  description: String,
  launchDate: LocalDate,
)

Para llevar este cambio a la base de datos necesito decidir cómo se van a guardar los precios:

  • Podría almacenar cada precio con su moneda, pero eso dificultaría las búsquedas por rango de precio y también sería más difícil ordenar por precio.
  • Otra opción sería guardar todos los precios en las diferentes monedas, pero la tasa de cambio entre monedas podría cambiar y sería muy difícil hacer todas las actualizaciones.
  • Finalmente, lo que voy a hacer es guardar todos los precios en la misma moneda y voy a hacer los cambios de moneda en la aplicación, aplicando la tasa de cambio vigente.

Así pues, el único cambio que voy a hacer en base de datos es renombrar la columna price a price_in_eur:

1
2
3
4
5
6
7
8
CREATE TABLE garments (
  id BIGINT NOT NULL PRIMARY KEY,
  model TEXT NOT NULL,
  price_in_eur NUMERIC(9,5) NOT NULL
    CHECK (price_in_eur > 0),
  description TEXT NOT NULL,
  launch_date DATE NOT NULL
);

Sin embargo, este pequeño cambio me obliga a repensar cómo voy a traer los datos del modelo relacional a mi modelo de negocio. En mi base de datos el precio es una cantidad sin dimensiones, mientras que en el modelo cada precio está acompañado de su moneda.

Para representar esta diferencia voy a introducir la clase GarmentRow que representa una entrada en la base de datos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
case class GarmentRow(
  id: Long,
  model: String,
  priceInEur: Double,
  description: String,
  launchDate: LocalDate,
):
  def toGarment: Garment = 
    this.into[Garment]
    .transform(
      Field.computed(_.price, x => moneyContext.defaultCurrency(x.priceInEur))
    )

Además de contener los datos de la prenda tal y como están en la base de datos, esta clase sabe aplicar las transformaciones necesarias para llevar los datos al modelo de negocio. En este caso, la clase sabe transformar el precio a la moneda con la que quiero trabajar en cada momento.

Para esta transformación me he apoyado en la librería ducktape, que permite transformar entre dos case classes centrándonos solamente en aquellos campos que son diferentes.

Además, la transformación necesita que haya un MoneyContext que defina cuál es la moneda con la que quiero trabajar (en este caso EUR porque es la moneda en la que están almacenados los precios en la base de datos):

1
2
3
4
5
6
7
object EuroMoneyContext:
  given moneyContext: MoneyContext =
    MoneyContext(
      defaultCurrency = EUR,
      currencies = Set(EUR, GBP, USD),
      rates = List(EUR / GBP(0.84d), EUR / USD(1.03d)),
    )

También he aprovechado el MoneyContext para definir las tasas de cambio entre las monedas soportadas por mi aplicación. De esta forma puedo cambiar la moneda de la prenda utilizando las tasas de cambio que están definidas en el contexto:

1
2
val garment = Garment(???, ???, price = EUR(20), ???, ???)
garment.copy(price = garment.price.in(USD))

Segundo cambio: impuestos#

Además de tener diferentes monedas, los países aplican diferentes impuestos a la comercialización de bienes:

1
2
3
4
5
6
7
8
case class Garment(
  id: Long,
  model: String,
  price: Money,
  tax: Double,
  description: String,
  launchDate: LocalDate,
)

Generalmente, los impuestos se aplican a diferentes grupos de bienes o servicios. Por ejemplo, la mayoría de los bienes pagan el tipo general, mientras que el tipo reducido se aplica a algunos bienes o servicios considerados de primera necesidad. Por el contrario, otros bienes son considerados de lujo y por eso se les aplica un tipo más elevado.

Teniendo en cuenta lo anterior, voy a crear en la base de datos el tipo sales_tax para representar los tipos impositivos de impuestos:

1
2
3
4
5
6
CREATE TYPE sales_tax AS ENUM (
  'luxury goods',
  'standard rate',
  'reduced rate',
  'essential goods'
);

Además, he creado la tabla taxes que define cuánto se paga por cada tipo. Por ejemplo, 21% por el tipo general, 10% por el tipo reducido, etc:

1
2
3
4
5
6
7
CREATE TABLE taxes (
  id SMALLINT NOT NULL PRIMARY KEY,
  tax sales_tax NOT NULL,
  rate NUMERIC(3,2) NOT NULL DEFAULT 0
    CHECK (rate >= 0.00 AND rate <= 1.00),
  UNIQUE (tax)
);

Con estos cambios puedo hacer que cada prenda esté anotada en base de datos con el tipo impositivo de impuesto que se le aplica:

1
2
3
4
5
6
7
8
9
CREATE TABLE garments (
  id BIGINT NOT NULL PRIMARY KEY,
  model TEXT NOT NULL,
  price_in_eur NUMERIC(9,5) NOT NULL
    CHECK (price_in_eur > 0),
  tax sales_tax NOT NULL,
  description TEXT NOT NULL,
  launch_date DATE NOT NULL
);

De esta forma, si quiero recuperar una prenda mediante su identificador como hacía la función findGarmentById introducida anteriormente, tendría que hacer una JOIN con la tabla de taxes para saber cuánto se paga por el tipo de impuesto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SELECT 
  garments.id, 
  garments.model, 
  garments.price, 
  taxes.rate, 
  garments.description, 
  garments.launch_date
FROM garments INNER JOIN taxes
ON garments.sales_tax = taxes.sales_tax
WHERE garments.id = '1000';

Al igual que con las monedas, este cambio me obliga a repensar el diseño de las salidas de la base de datos, y demás, esta vez también me va a obligar a pensar en las entradas. Vayamos paso a paso.

Antes había definido la clase GarmentRow para representar una entrada en base de datos, y me servía tanto para insertar una prenda nueva como para recuperar la información de una prenda de la base de datos.

Con la introducción de los impuestos necesito dos clases diferentes. La clase GarmentDbOut me va a servir para recoger la salida de la JOIN definida más arriba:

1
2
3
4
5
6
7
8
case class GarmentDbOut(
  id: Long,
  model: String,
  priceInEur: Double,
  tax: Double,
  description: String,
  launchDate: LocalDate,
)

Para introducir nuevas prendas en la base de datos voy a utilizar la nueva clase GarmentDbIn junto con la enumeración SalesTax, que refleja el nuevo tipo sales_tax creado en la base de datos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
enum SalesTax(val value: String):
  case LuxuryGoods extends SalesTax("luxury goods")
  case StandardRate extends SalesTax("standard rate")
  case ReducedRate extends SalesTax("reduced rate")
  case EssentialGoods extends SalesTax("essential goods")

case class GarmentDbIn(
  id: Long,
  model: String,
  priceInEur: Double,
  salesTax: SalesTax,
  description: String,
  launchDate: LocalDate,
)

Primera mejora: tipos refinados#

Hasta ahora he utilizado tipos primitivos en mi modelo de negocio, con la única excepción del tipo Money que utilicé para representar el precio. Sin embargo, en la base de datos he ido introducciendo restricciones a los tipos. Por ejemplo, taxes.rate es un número entre 0 y 1, garments.price_in_eur es positivo distinto de cero, etc.

Esto quiere decir, que mi modelo relacional está más refinado que mi modelo de negocio. Voy a hacer que las restricciones coincidan en ambos modelos.

Con la ayuda de la librería Iron he definido las restricciones que me permiten refinar los tipos que utiliza mi modelo de negocios:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
case class Garment(
  id: Garment.Id,
  model: Garment.Model,
  price: Money,
  tax: Garment.Tax,
  description: Garment.Description,
  launchDate: LocalDate,
)

object Garment:
  opaque type Id <: Long :| Positive0 = Long :| Positive0
  object Id extends RefinedTypeOps[Long, Positive0, Id]

  opaque type Model <: String :| NonEmptyString = String :| NonEmptyString
  object Model extends RefinedTypeOps[String, NonEmptyString, Model]

  opaque type Tax <: Double :| UnitFraction = Double :| UnitFraction
  object Tax extends RefinedTypeOps[Double, UnitFraction, Tax]

  opaque type Description <: String :| NonEmptyString = String :| NonEmptyString
  object Description extends RefinedTypeOps[String, NonEmptyString, Description]

Por ejemplo, el tipo UnitFraction se define como un intervalo cerrado entre 0 y 1:

1
type UnitFraction = Interval.Closed[0d, 1d]

De esta forma, cada uno de los campos de mi modelo queda tipado y refinado.

Tercer y último cambio: ahora vendemos electrodomésticos#

El negocio se expande y además de la ropa quiero ofrecer nuevos tipos de productos en mi tienda online. Para empezar, voy a añadir electrodomésticos al catálogo:

1
2
3
4
5
6
7
8
9
final case class ElectronicDevice(
  id: ElectronicDevice.Id,
  model: ElectronicDevice.Model,
  powerConsumption: Power,
  price: Money,
  tax: ElectronicDevice.Tax,
  description: ElectronicDevice.Description,
  launchDate: LocalDate,
)

El modelo de negocio es muy parecido, y tiene algunos campos que coinciden con la ropa, como el precio y los impuestos. Además, la representación en base de datos aprovecha lo que ya había hecho hasta ahora:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CREATE TABLE electronics (
  id BIGINT NOT NULL PRIMARY KEY,
  model TEXT NOT NULL,
  power_consumption_in_watts NUMERIC(6,2) NOT NULL
    CHECK (power_consumption_in_watts >= 0),
  price_in_eur NUMERIC(9,5) NOT NULL
    CHECK (price_in_eur > 0),
  tax sales_tax NOT NULL,
  description TEXT NOT NULL,
  launch_date DATE NOT NULL
);

Habiendo llegado a este punto, lo más lógico sería definir abstracciones para trabajar con los dos tipos de productos que tenemos hasta ahora: ropa y electrodomésticos.

Segunda mejora: abstracciones#

Para mi catálogo necesito una operación que me permita filtrar productos los campos más relevantes, y también ordenar los listados de productos que generen estos filtros. Por ejemplo, una usuaria puede estar interesada en los Smart TV que tengan un consumo por debajo de los 150 W, y además quiere verlos ordenados por precio ascendente.

De forma semejante, otra usuaria puede estar interesada en los pantalones vaqueros de nueva temporada.

En resumen, necesito una forma de deninir qué campos se pueden utilizar para filtrar y cuáles para ordenar cada producto.

Filtrado#

Para el filtrado voy a definir los filtros, que pueden ser combinaciones (And, In, Or, Not) o comparadores (Between, Equal, Greater, Less, Like):

1
2
3
4
5
6
7
8
sealed trait Filter:
  def sql(using formatter: ColumnFormatter = ColumnFormatter.SimpleName): Fragment

object Filter:
  sealed trait Combinator extends Filter
  sealed trait Comparator extends Filter
  case object NoFilter extends Filter:
    override def sql(using formatter: ColumnFormatter): Fragment = Fragment.empty

Cada filtro recibe los argumentos que necesita. Por ejemplo, el filtro And recibe un listado de filtros que se combinan con la operación lógica AND:

1
case class And(filters: Filter*) extends Combinator

Algunos filtros reciben un tipo de argumento con un context bound de tipo Put que además necesita un tipo Filterable en el contexto:

1
case class Equal[T: Put](to: T)(using filterable: Filterable[T]) extends Comparator

El tipo Put no es más que una transformación de doobie entre el tipo T y el tipo equivalente en base de datos. En cambio, Filterable es una abstracción propia que marca aquellas columnas que se pueden utilizar para filtrar los datos. Aprovechando la librería Tagging he marcado las columnas con una etiqueta que evita que se puedan construir filtros que no están soportados por la aplicación:

1
2
3
4
5
6
class Column[T](val table: Table.Name, val column: String):
  val fqn = s"$table.$column"

object Column:
  type FilterableTag
  type Filterable[T] = Column[T] @@ FilterableTag

Además, cada filtro define la operación sql que transforma el filtro en una secuencia de operaciones en lenguaje SQL. La enumeración ColumnFormatter permite definir si las columnas utilizan su nombre simple (e.g. priceInEur) o su nombre completo (e.g. garments.priceInEur):

1
2
3
4
5
6
enum ColumnFormatter:
  case SimpleName, FullQualifiedName
  def format[T](column: Column[T]): String =
    this match
      case ColumnFormatter.SimpleName => column.column
      case ColumnFormatter.FullQualifiedName => column.fqn

Ordenación#

La ordenación puede ser ascendente o descendente, y al igual que los filtros definen una operación sql para transformar la ordenación en un fragmento válido en SQL:

1
2
sealed trait Sort:
  def sql: Fragment

Como en el caso de los filtros, hay una etiqueta Sortable que marca aquellas columnas que pueden ser utilizadas para la ordenación:

1
case class Ascending[T](sortable: Sortable[T]) extends Sort

Juntándolo todo en una tabla#

Una tabla es una abstracción que define un tipo en base de datos, junto con sus operaciones de inserción, búsqueda por identificador y creación de listas filtradas y ordenadas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
trait Table[Id: Put: Filterable, Input, Output: Read]:
  implicit val name: Table.Name

  def sql: Fragment = Fragment.const(name)
  def read: List[Column[_]]
  def write(value: Input): Table.Write

  def insert(item: Input): ConnectionIO[Int] =
    val sql = fr"INSERT INTO" ++ this.sql ++ write(item).sql
    sql.update.run

  def findBy(id: Id): ConnectionIO[Option[Output]] =
    val select = fr"SELECT" ++ comma(read) ++ fr"FROM" ++ this.sql
    val sql = select ++ where(Equal(id))
    sql.query[Output].option

  def selectBy(filter: Filter, sort: Sort, chunkSize: Int): 
    Stream[ConnectionIO, Output] =
      val select = fr"SELECT" ++ comma(read) ++ fr"FROM" ++ this.sql
      val sql = select ++ where(filter) ++ orderBy(sort)
      sql.query[Output].streamWithChunkSize(chunkSize)

object Table:
  type TableNameTag
  type Name = String @@ TableNameTag

  final case class Write(columns: List[Column[_]], values: Fragment):
    def sql: Fragment = 
      parentheses(comma(columns)) ++ fr"VALUES" ++ parentheses(values)

Cada tabla tiene 3 parámetros de tipo:

  • El Id con los context bound de tipo Put y Filterable que hacen posible la operación de búsqueda por Id (findBy).
  • El Input que es el tipo que utiliza la inserción.
  • El Output con el context bound de tipo Read que es el equivalente de Put para los tipos con múltiples columnas.

El Input y el Output pueden ser iguales o diferentes, según se necesite. También se puede usar un modelo de negocio si no fuera necesario hacer ninguna transformación.

Ejemplo final#

Así quedaría por ejemplo, la clase GarmentTable que define en mi catálogo las operaciones para trabajar con prendas de ropa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
object GarmentTable:
  case class GarmentDbIn(...) // omitted for brevity

  case class GarmentDbOut(...) // omitted for brevity

  implicit private val tableName: Table.Name = 
    "garments".taggedWith[Table.TableNameTag]

  // Column definitions
  private val idColumn = filterable[Garment.Id]("id")
  private val modelColumn = filterable[Garment.Model]("model")
  private val priceColumn = filterableAndSortable[Money]("price_in_eur")
  private val rateColumn = column[Garment.Tax]("rate")(using TaxesTable.Impl.name)
  private val taxColumn = column[SalesTax]("tax")
  private val descriptionColumn = filterable[Garment.Description]("description")
  private val launchDateColumn = filterableAndSortable[LocalDate]("launch_date")

  // Filterable columns
  given Filterable[Garment.Id] = idColumn
  given Filterable[Garment.Model] = modelColumn
  given Filterable[Money] = priceColumn
  given Filterable[LocalDate] = launchDateColumn

  // Sortable columns
  given sortablePrice: Sortable[Money] = priceColumn
  given sortableLaunchDate: Sortable[LocalDate] = launchDateColumn

  // Doobie mappers
  given Meta[Garment.Id] = Meta[Long].tiemap(Garment.Id.either)(_.value)
  given Meta[Garment.Model] = Meta[String].tiemap(Garment.Model.either)(_.value)
  given Meta[Money] = 
    Meta[BigDecimal].timap(euroContext.defaultCurrency.apply)(
      _.in(euroContext.defaultCurrency).amount,
    )
  given Meta[Garment.Tax] = Meta[Double].tiemap(Garment.Tax.either)(_.value)
  given Meta[Garment.Description] = 
    Meta[String].tiemap(Garment.Description.either)(_.value)

  object Impl extends Table[Garment.Id, GarmentDbIn, GarmentDbOut]:
    implicit override val name: Table.Name = tableName

    override def read: List[Column[_]] = List(
      idColumn,
      modelColumn,
      priceColumn,
      rateColumn,
      descriptionColumn,
      launchDateColumn,
    )

    override def write(garmentDbIn: GarmentDbIn): Table.Write = Table.Write(
      List(
        idColumn,
        modelColumn,
        priceColumn,
        taxColumn,
        descriptionColumn,
        launchDateColumn,
      ),
      sql"""${garmentDbIn.id},
           |${garmentDbIn.model},
           |${garmentDbIn.priceInEur},
           |${garmentDbIn.salesTax},
           |${garmentDbIn.description},
           |${garmentDbIn.launchDate}""".stripMargin,
    )

    override def findBy(id: Garment.Id): ConnectionIO[Option[GarmentDbOut]] =
      given ColumnFormatter = ColumnFormatter.FullQualifiedName
      val select = fr"SELECT" ++ comma(read) ++ join(
        taxColumn,
        TaxesTable.taxColumn,
      )
      val sql = select ++ where(Equal(id))
      sql.query[GarmentDbOut].option

    override def selectBy(
        filter: Filter,
        sort: Sort,
        chunkSize: Int,
    ): Stream[ConnectionIO, GarmentDbOut] =
      given ColumnFormatter = ColumnFormatter.FullQualifiedName
      val select = fr"SELECT" ++ comma(read) ++ join(
        taxColumn,
        TaxesTable.taxColumn,
      )
      val sql = select ++ where(filter) ++ orderBy(sort)
      sql.query[GarmentDbOut].streamWithChunkSize(chunkSize)

Todas las columnas reciben el nombre de la tabla del Table.Name implícito en el contexto. Se puede filtrar la ropa por el identificador, el modelo, el precio y la fecha de lanzamiento. Las columnas que se pueden utilizar para ordenar son el precio y la fecha de lanzamiento.

Se han reescrito las operaciones de búsqueda por identificador y la creación de listados filtrados y ordenados para recuperar el valor del impuesto que aplica a cada prenda.

Cierre#

He creado un modelo con tipos refinados que luego he aprovechado para crear abstracciones que han terminado convirtiéndose en un DSL para insertar, seleccionar y crear listados de productos en un catálogo.

Me parece un buen punto de partida sobre el que seguir iterando. En especial me gustaría encontrar un modo de ocultar los detalles de la base de datos durante la creación de los filtros (evitar el context bound de tipo Put).

El código completo del ejemplo está disponible en GitHub1.

Iron

  1. Foto de Deon Black en Unsplash ↩︎