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:
-
¿De dónde sale
id
? ¿De qué tipo es? -
¿De qué tipo son
inserted_at
yupdated_at
? - ¿Qué tipos puedo usar para describir los campos?
-
¿De dónde sale
-
Ya tenemos un schema y un repo ¿quizás podamos ya añadir datos?
-
Podemos intentar el Schema API de
Repo
, en concretoRepo.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 demix 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 atyped_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 perohas_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
eindex
son unique)
…Famous Last Words…
-
Cuidado con
Changeset.validate_required
para claves foráneas pq entonces no funcionanChangeset.put_assoc
niChangeset.cast_assoc
, de hecho explícitamente pone que no se use en la documentación deChangeset.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
yput_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 dechangeset
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