Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Una chuleta para manejarte en Ecto ▷ Madrid ▷ Elixir

chuleta_ecto.livemd

Una chuleta para manejarte en Ecto ▷ Madrid ▷ Elixir

Mix.install([
  # Disponible con el mismo git en el que está este notebook
  {:chuleta, path: "#{__DIR__}/chuleta"},

  # Me gustan los tipos, aunque sólo sea para documentar
  {:typed_ecto_schema, "~> 0.4.0"}
])

ANTES DE NADA: mix do ecto.drop, ecto.create

Antes de empezar

  • He intentado hacerlo todo desde livebook y casi lo consigo 😜
  • El repo git incluye un pequeño proyecto chuleta con una aplicación OTP y un repositorio Ecto (justo eso es lo que no he logrado hacer desde este notebook)
  • Me habría gustado preparar más contenido pero entre lo que me he liado aprendiendo a usar livebook y un pequeño proceso gripal…
  • Aunque un poco caótico, creo valdrá para aprender y debatir juntos

Objetivos

  • Compartir con vosotros esta espectacular biblioteca
  • Ofrecer una presentación rápida y didáctica (espero) a los principales conceptos
  • Ofrecer algunos patrones de código que creo que pueden resultar muy últiles
  • Aparecer indexado por primera vez en notes.club 🤩 (disponible en https://github.com/aherranz/notebooks)

¿Qué es Ecto?

  • Biblioteca de Elixir
  • Equivalente a un ORM en OO (object-relational mapping)
  • ¡Pero nada de objetos!
  • Expone al programador al modelo relacional 👍
  • Uso intenso de la metaprogramación: DSL para las queries (DSL = domain specific language)
  • No se escribe código SQL 🤥

Conceptos

  • Schema: estructura de datos para el mapping entre una tabla SQL y un struct en Elixir (con confundir con el esquema relacional)
  • Changeset: 👑 estructura de datos para acumular cambios y validarlos.
  • Queries: estructura de datos para codificar queries a la base de datos.
  • Repo: módulo con operaciones sobre la base de datos (insert, update, delete, all, get, one, transactions)
  • Migration: módulo con operaciones para modificar el esquema de la base de datos (create table, alter column, etc.), incluyendo rollbacks automáticos
graph TD;
  Schema -->|cambios y validaciones| Changeset;
  Datos[Datos Externos] --->|validaciones| Changeset;
  Changeset -->|cambios y validaciones| Changeset;
  Changeset -->|schema API: insert, update| Repo;
  Queries -->|query API: all, get, delete| Repo;
  Repo -->|resultado| Schema;
  Repo <--> Database[(Database)]

Preparando la base de datos y el repositorio

  • Vamos a trabajar con una base de datos PostgreSQL de verdad asi que necesitamos usuario y password
sudo -u postgres psql -c "create role chuleta login password 'chuleta' createdb;"
sudo -u postgres psql -c "create database chuleta owner chuleta;"
  • Creamos un repo para conectarlo a la base de datos, ver chuleta/lib/chuleta/repo.ex:
defmodule Chuleta.Repo do
  use Ecto.Repo,
    otp_app: :chuleta,
    adapter: Ecto.Adapters.Postgres

  def init(_context, config) do
    {:ok, Keyword.put(config, :url, "ecto://chuleta:chuleta@localhost/chuleta")}
  end
end
  • Y configuraciones en chuleta/config/config.exs:
import Config
config :chuleta, ecto_repos: [Chuleta.Repo]
  • Es necesario crear la base de datos con:
mix ecto.create

Comprobaciones previas

  • La aplicación OTP :chuleta está en marcha
tl(Application.started_applications())
  • El repositorio está configurado
alias Chuleta.Repo
Repo.config()

Ejemplo: modelo conceptual

  • Vamos a usar el mismo ejemplo del libro Programming Ecto de Darin Wilson Eric Meadows-Jönsson
  • Base de datos de álbumes musicales: artistas, álbumes, géneros, pistas
                                                    classDiagram
                                                    class Album {
                                                      id: integer [PK]
                                                      title: string [1]
                                                      released: date
                                                    }
                                                    class Artist {
                                                      id: integer [PK]
                                                      name: string [1] [unique]
                                                    }
                                                    class Genre {
                                                      id: integer [PK]
                                                      name: string [1] [unique]
                                                    }
                                                    class Track {
                                                      id: integer [PK]
                                                      title: string [1]
                                                      duration: integer
                                                    }
                                                    Album "*" -- "1" Artist
                                                    Album "*" -- "1..*" Genre
                                                    Album "1" -- "1..* {ordered}" Track
  • Seguro que el modelo es debatible dependiendo de lo que uno quiera entender, nos ceñimos a la semántica del diagrama, que a mi tampoco me gusta 😀

Un primer modelo

  • Creamos un schema por cada entidad del modelo, empezamos con Artist
defmodule Chuleta.Model1.Artist do
  use Ecto.Schema

  schema "artists" do
    field(:name, :string)

    # Regalo: macro para manejar automáticamente fechas de inserción y borrado
    timestamps()
  end
end
alias Chuleta.Model1.Artist
  • Exploremos el aspecto de los datos (struct Artist)
%Artist{}
  • Seguro que ya nos asaltan algunas dudas:

  • Ya tenemos un schema y un repo ¿quizás podamos ya añadir datos?

  • Podemos intentar el Schema API de Repo, en concreto Repo.insert

Repo.insert(%Artist{})
  • Obviamente necesitábamos la tabla asociada
  • La creación de una tabla se define en una migración como esta:
defmodule Chuleta.Repo.Migrations.CreateArtistsTable do
  use Ecto.Migration

  def change do
    create table("artists") do
      add(:name, :string)

      timestamps()
    end
  end
end
  • Y se ejecuta con Ecto.Migrator (por suerte se dispone de mucha ayuda de mix ecto.migrate aunque aquí no lo vamos a usar)
Ecto.Migrator.run(Repo, [{0, Chuleta.Repo.Migrations.CreateArtistsTable}], :up, all: true)
  • Volvemos a intentar ahora la inserción, varias veces
Repo.insert(%Artist{})
  • E inmediatamente la extracción
Repo.all(Artist)

Protegemos el modelo lógico

  • El modelo conceptual dice que la multiplicidad de name es [1]
  • La migración puede incluir restricciones sobre el modelo lógico como NOT NULL:
                                  def change do
                                    create table("artists") do
                                      add(:name, :string, null: false)
    
                                      timestamps()
                                    end
                                  end
  • Vamos a hacerlo con una nueva migración
defmodule Chuleta.Repo.Migrations.NameNotNull do
  use Ecto.Migration

  def change do
    alter table("artists") do
      modify(:name, :string, null: false)
    end
  end
end
Ecto.Migrator.run(Repo, [{1, Chuleta.Repo.Migrations.NameNotNull}], :up, all: true)
  • Se puede hacer un rollback automáticamente
Ecto.Migrator.run(Repo, [{0, Chuleta.Repo.Migrations.CreateArtistsTable}], :down, all: true)
Ecto.Migrator.run(
  Repo,
  [{0, Chuleta.Repo.Migrations.CreateArtistsTable}, {1, Chuleta.Repo.Migrations.NameNotNull}],
  :up,
  all: true
)
  • Reintentamos la inserción esperando un error de algún tipo
Repo.insert(%Artist{})

Buscando errores más orgánicos

  • Hemos visto que el error no es muy orgánico, de hecho es una excepción
  • Proteger el modelo lógico es estupendo pero no queremos tener que manejar excepciones
  • El concepto de changeset nos va a ayudar con ese control de errores
                                graph TD;
                                  Data[Data / Schema] -->|Changeset.change| Changeset;
                                  Changeset -->|Changeset.cast, validate| Changeset;
                                  Changeset -->|Repo.insert, update| Schema;
alias Ecto.Changeset
%Artist{}
|> Changeset.change()
|> Changeset.validate_required([:name])
|> Repo.insert()
|> dbg
  • ✅ Se puede intuir lo esencial que resulta el changeset en Ecto, todo gira alrededor de este importante concepto, todos los módulos tienen funciones que admiten o generan changesets

El struct Changeset

  • Merece la pena explorar los campos del struct Ecto.Changeset.t():
    • :data el struct sobre el que aplicar los cambios.
    • :params map con los cambios que se desea realizar.
    • :changes cambios ya aprobados.
    • :errors errores detectados en el casting y validaciones.
    • :valid? decide si el changeset es válido o ha habido errores.
    • :required lista de campos obligatorios (multiplicidad no 0).
    • :action representa la acción que se intenta realizar (:insert, :update, :delete).
    • :types es un campo que permite la introspección de los nombres de los campos y sus tipos en el struct.
    • :empty_values lista de valores que en el proceso de casting y validación se consideran vacíos.
    • :repo es un valor que se devuelve en el changeset de las funciones del repo que se use.
    • :repo_opts opciones que se estén empleando en el repo usado.

Patrón: funciones que generan changesets

  • Cada schema lleva su propia función para generar changesets: changeset
  • Dicha función abstrae las validaciones y adaptaciones (cast) de datos externos
defmodule Chuleta.Model2.Artist do
  use TypedEctoSchema
  alias Ecto.Changeset

  typed_schema "artists" do
    field(:name, :string)

    # Regalo: macro para manejar automáticamente fechas de inserción y borrado
    timestamps()
  end

  @spec changeset(t() | Changeset.t(t()), map()) :: Changeset.t(t())
  def changeset(data, params) do
    data
    |> Changeset.cast(params, [:name])
    |> Changeset.validate_required([:name])
    |> Changeset.unique_constraint(:id, name: "artists_pkey")
    |> Changeset.unique_constraint(:name)
  end
end
alias Chuleta.Model2.Artist
  • Insertamos un primer artista
{:ok, a1} =
  %Artist{}
  |> Artist.changeset(%{name: "Supertrump"})
  |> Repo.insert()
  • Intentamos corregir el error: Supertrump -> Supertramp
a1
|> Artist.changeset(%{name: "Supertramp"})
|> Repo.update()
  • Una consulta rápida al Schema API de Repo nos indica que debemos usar update
  • Pero además deberíamos volver al schema y mejorar la función changeset como nos indica el mensaje de error (para más información conviene bucear en el API de Changeset)

Patrón: proteger el modelo lógico y los esquemas

  • Parece que podemos añadir varias veces el mismo nombre de artista
%Artist{} |> Artist.changeset(%{name: "Supertramp"}) |> Repo.insert()
Repo.all(Artist)
  • El modelo conceptual nos dice que name es [unique]
  • Vamos a trasladarlo al modelo lógico
defmodule Chuleta.Repo.Migrations.AddUniqueIndexName do
  use Ecto.Migration

  def change do
    create(unique_index("artists", :name))
  end
end
Ecto.Migrator.run(Repo, [{3, Chuleta.Repo.Migrations.AddUniqueIndexName}], :up, all: true)
Repo.delete_all(Artist)
Ecto.Migrator.run(Repo, [{3, Chuleta.Repo.Migrations.AddUniqueIndexName}], :up, all: true)
  • Comprobamos ahora el resultado al intentar añadir dos artistas con el mismo nombre

Antipatrón: no usar changesets con datos “externos”

a = Repo.get_by(Artist, name: "Supertramp")
Repo.update(%Artist{a | name: "Supertramp. The group"})
Repo.insert(%Artist{name: "Pink Floyd"})

Asociaciones N:1: “colecciones”

  • Lo realmente importante en nuestros modelos son las asociaciones porque las entidades son un placebo 🤪
  • Ecto, como otras bibliotecas, ofrece una forma de capturar las asociaciones del modelo y sus multiplicidades
  • Recordemos el modelo:
                        classDiagram
                                                    class Album {
                                                      id: integer [PK]
                                                      title: string [1]
                                                      released: date
                                                    }
                                                    class Artist {
                                                      id: integer [PK]
                                                      name: string [1] [unique]
                                                    }
                                                    class Genre {
                                                      id: integer [PK]
                                                      name: string [1] [unique]
                                                    }
                                                    class Track {
                                                      id: integer [PK]
                                                      title: string [1]
                                                      duration: integer
                                                    }
                                                    Album "*" -- "1" Artist
                                                    Album "*" -- "1..*" Genre
                                                    Album "1" -- "1..* {ordered}" Track
  • Nos llevamos el modelo a schemas incluyendo ahora Artist, Album y su asociación $N:1$ (atención a typed_schema y al spec de changeset)
defmodule Chuleta.Model3.Artist do
  use TypedEctoSchema
  alias Ecto.Changeset
  alias Chuleta.Model3.Album

  typed_schema "artists" do
    field(:name, :string)
    has_many(:albums, Album)

    timestamps()
  end

  @spec changeset(t() | Changeset.t(t()), map()) :: Changeset.t(t())
  def changeset(data, params) do
    data
    |> Changeset.cast(params, [:name])
    |> Changeset.validate_required([:name])
    |> Changeset.unique_constraint(:id, name: "artists_pkey")
    |> Changeset.unique_constraint(:name)
  end
end
defmodule Chuleta.Model3.Album do
  use TypedEctoSchema
  alias Ecto.Changeset
  alias Chuleta.Model3.Artist

  typed_schema "albums" do
    field(:title, :string)
    field(:released, :date)
    belongs_to(:artist, Artist)

    timestamps()
  end

  def changeset(data, params) do
    data
    |> Changeset.cast(params, [:title, :released])
    |> Changeset.validate_required([:title, :artist_id])
    |> Changeset.unique_constraint(:id, name: "albums_pkey")
  end
end
  • ¿Qué aspecto tienen ahora nuestros datos?
alias Chuleta.Model3.{Artist, Album}
%Artist{}
%Album{}
  • Observar: como era de esperar, belongs_to introduce una clave foránea (artist_id) en el modelo lógico pero has_many no va a introducir nada

  • Vamos con la migración para crear la nueva tabla

defmodule Chuleta.Repo.Migrations.CreateAlbumsTable do
  use Ecto.Migration

  def change do
    create table("albums") do
      add(:title, :string, null: false)
      add(:released, :date)
      add(:artist_id, references("artists"), null: false)

      timestamps()
    end
  end
end
Ecto.Migrator.run(Repo, [{4, Chuleta.Repo.Migrations.CreateAlbumsTable}], :up, all: true)
  • Vamos a poblar un poquito nuestra base de datos con datos razonables, que no con gusto 😜
Repo.delete_all(Artist)
%Artist{} |> Artist.changeset(%{name: "C. Tangana"}) |> Repo.insert()
tangana = Repo.get_by(Artist, name: "C. Tangana")
%Album{} |> Album.changeset(%{title: "El Madrileño", released: "2021-02-26"})

Patrón: añadir a una “colección” de uno en uno

  • Ecto.build_assoc genera un nuevo schema (no lo hace persistente)
tangana
tangana |> Ecto.build_assoc(:albums)
  • Vamos a crear un par de álbumes usando dos alternativas habituales
tangana |> Ecto.build_assoc(:albums, title: "Idolo", released: "2017-10-26") |> Repo.insert()
  • ¡Eso era un antipatrón! (la fecha parece ser un dato externo sobre el que no se tiene control)
tangana
|> Ecto.build_assoc(:albums)
|> Album.changeset(%{title: "El Madrileño", released: "2021-02-26"})
|> Repo.insert()

Preload

tangana = Repo.get_by(Artist, name: "C. Tangana")
  • ¿Para qué está ese campo albums y qué significa not loaded?
  • Por defecto las asociaciones no se precargan: eficiencia
tangana |> Repo.preload(:albums)
tangana.albums
  • ¿Qué ha pasado, programador OO?

Antipatrón: preload => 1 + X queries

Repo.get_by(Artist, name: "C. Tangana") |> Repo.preload(:albums)

Poblado con más gusto 😜

# Por si acaso está en la base de datos
supertramp = Repo.get_by!(Artist, name: "Supertramp")
{:ok, supertramp} = %Artist{} |> Artist.changeset(%{name: "Supertramp"}) |> Repo.insert()
supertramp
supertramp |> Ecto.build_assoc(:albums, title: "Crime of the century") |> Repo.insert()
supertramp |> Ecto.build_assoc(:albums, title: "Crisis? What Crisis?") |> Repo.insert()

Queries

  • Hasta ahora nuestras queries han sido nimias (Repo.get_by)
  • En el query API de Repo se pueden encontrar operaciones más potentes
Repo.all(Artist)
  • ¿Y si queremos hacer una precarga de todos los álbumes de todos los artistas?
for artist <- Repo.all(Artist) do
  artist |> Repo.preload(:albums)
end
  • Ese antipatrón ya lo conocemos: 1+N queries
  • ¿Pero qué opciones tenemos?
Repo.all(Artist) |> Repo.preload(:albums)
  • ¿Y el DSL de Ecto.Query?
import Ecto.Query, only: [from: 2]

Repo.all(from(a in Artist, preload: :albums))

Patrón: preload con join

import Ecto, only: [assoc: 3]

Repo.all(
  from(ar in Artist,
    left_join: al in assoc(ar, :albums),
    preload: [albums: al]
  )
)

Manejando la colección como un todo (WIP ⚠)

  • Ya hemos visto que el uso de Ecto.build_assoc permite crear una entrada en una colección
  • ¿Pero qué pasa si necesitamos manejar la colección como un todo?
  • Ampliemos nuestro modelo con las canciones en los álbumes
defmodule Chuleta.Model4.Track do
  use TypedEctoSchema
  alias Ecto.Changeset
  alias Chuleta.Model3.Album

  typed_schema "tracks" do
    field(:title, :string)
    # ordered!
    field(:index, :integer)
    belongs_to(:album, Album)

    timestamps()
  end

  def changeset(data, params) do
    data
    |> Changeset.cast(params, [:title, :index])
    |> Changeset.validate_required([:title, :index])
    |> Changeset.unique_constraint(:id, name: "tracks_pkey")
  end
end
  • Añadimos has_many tracks a los álbumes
defmodule Chuleta.Model4.Album do
  use TypedEctoSchema
  alias Ecto.Changeset
  alias Chuleta.Model3.Artist
  alias Chuleta.Model4.Track

  typed_schema "albums" do
    field(:title, :string)
    field(:released, :date)
    belongs_to(:artist, Artist)
    has_many(:tracks, Track)

    timestamps()
  end

  def changeset(data, params) do
    data
    |> Changeset.cast(params, [:title, :released])
    |> Changeset.validate_required([:title, :artist_id])
    |> Changeset.unique_constraint(:id, name: "albums_pkey")
  end
end
  • Y por supuesto la migración correspondiente
defmodule Chuleta.Repo.Migrations.CreateTracksTable do
  use Ecto.Migration

  def change do
    create table("tracks") do
      add(:title, :string, null: false)
      add(:index, :integer, null: false)
      add(:album_id, references("albums"), null: false)

      timestamps()
    end
  end
end
Ecto.Migrator.run(Repo, [{5, Chuleta.Repo.Migrations.CreateTracksTable}], :up, all: true)
alias Chuleta.Model4.{Album, Tracks}
songs = [
  "School",
  "Bloody Well Right",
  "Hide in Your Shell",
  "Asylum",
  "Dreamer",
  "Rudy",
  "If Everyone Was Listening",
  "Crime of the Century"
]

tracks =
  for {i, t} <- Enum.zip(1..length(songs), songs) do
    %{index: i, title: t}
  end

album =
  Repo.one(
    from(al in Album,
      where: al.title == "Crisis? What Crisis?",
      left_join: ar in assoc(al, :artist),
      left_join: tr in assoc(al, :tracks),
      preload: [artist: ar, tracks: tr]
    )
  )

album |> Album.changeset(%{tracks: tracks}) |> Changeset.cast_assoc(:tracks) |> Repo.update()

WIP

  • Asociaciones N:N
  • Añadir todas las restricciones y validaciones que faltan (ej. en Track album_id e index son unique)

…Famous Last Words…

  • Cuidado con Changeset.validate_required para claves foráneas pq entonces no funcionan Changeset.put_assoc ni Changeset.cast_assoc, de hecho explícitamente pone que no se use en la documentación de Changeset.validate_required

  • belogs_to define una clave foránea en el modelo donde se declara mientras que has_many y has_one dependen de que haya un belongs_to en el otro modelo (es decir, una clave foránea)

  • cast_assoc y put_assoc se usa para moldear un modelo asociado y sólo se puede usar en has_one y has_many.

  • cast_assoc invocará la función de changeset del modelo asociado.

  • Cuando se tiene un belongs_to en un modelo y se quiere comprobar que el modelo asociado existe: assoc_constraint (es necesario que el modelo asociado tenga un has_one o un has_many)

  • Muy importante este post para poder hablar de multiplicidad mínima de 1: https://elixirforum.com/t/ecto-validating-belongs-to-association-is-not-nil/2665/5

  • Entender el mapping y los diferentes tipos (incluye listas, maps, enumerados)

  • Clave binaria vs entera

        @primary_key {:id, :binary_id, autogenerate: true}
        @foreign_key_type :binary_id
        schema "artists" ...

    > y

    create table("artists", primary_key: false) do
      add :id, :binary_id, primary_key: true
      ...
  • En las migraciones se pueden codificar todas las restricciones en SQL
  • Asociaciones transitivas (ej. todas las canciones de un grupo)
  • Asociaciones polimórficas (ej. urso con videos y preguntas)
  • Custom types
  • Upserts
  • Transactions
  • Infinidad de detalles que de un modo u otro tienen que ver con el modelo relacional (ej. on_conflict)
  • Mi experiencia con este notebook (vs. slides): aún no lo tengo claro