Cuando buscas cómo acceder a una base de datos en Elixir, la respuesta es Ecto. Pero Ecto no funciona como los ORMs que conoces de otros lenguajes. No es un mapper objeto-relacional que oculta el SQL debajo de capas de abstracción. Es una librería de acceso a datos que te da control explícito sobre cada operación, con un sistema de validación (los changesets) que separa limpiamente la lógica de negocio de la capa de persistencia.
Esquemas: la representación de los datos
Un schema en Ecto define la estructura de una tabla y cómo se mapea a una struct de Elixir. No es una clase con métodos de consulta: es solo la definición del tipo.
defmodule MiApp.Cuentas.Usuario do
use Ecto.Schema
import Ecto.Changeset
schema "usuarios" do
field :nombre, :string
field :email, :string
field :edad, :integer
field :activo, :boolean, default: true
field :password_hash, :string
has_many :articulos, MiApp.Blog.Articulo
belongs_to :empresa, MiApp.Empresa
timestamps()
end
@campos_requeridos [:nombre, :email]
@campos_opcionales [:edad, :activo]
def changeset(usuario, attrs) do
usuario
|> cast(attrs, @campos_requeridos ++ @campos_opcionales)
|> validate_required(@campos_requeridos)
|> validate_format(:email, ~r/^[^s]+@[^s]+.[^s]+$/)
|> validate_number(:edad, greater_than: 0, less_than: 150)
|> unique_constraint(:email)
end
end
Los timestamps() añaden automáticamente los campos inserted_at y updated_at, que Ecto gestiona solo.
Changesets: el núcleo de la validación
Un changeset representa una operación de cambio sobre un dato, junto con las validaciones que debe cumplir. No es el dato en sí, sino la intención de cambiarlo. Esto permite distinguir entre "el usuario tal como existe en base de datos" y "los cambios que queremos aplicar".
# Changeset para creación (sin id, todos los campos necesarios)
def changeset_creacion(attrs) do
%Usuario{}
|> cast(attrs, [:nombre, :email, :password])
|> validate_required([:nombre, :email, :password])
|> validate_length(:password, min: 8)
|> hash_password()
|> unique_constraint(:email)
end
# Changeset para actualización de perfil (solo algunos campos)
def changeset_perfil(usuario, attrs) do
usuario
|> cast(attrs, [:nombre, :edad])
|> validate_required([:nombre])
end
# Comprobar si es válido
changeset = Usuario.changeset_creacion(%{nombre: "Ana", email: "[email protected]", password: "secreto123"})
changeset.valid? # true o false
changeset.errors # lista de errores si los hay
La gracia de los changesets es que puedes tener varios por entidad, cada uno modelando una operación diferente con validaciones distintas. El changeset de creación de cuenta puede requerir contraseña; el de actualización de perfil, no.
Repo: el punto de acceso a la base de datos
El Repo es el módulo que ejecuta las operaciones contra la base de datos. Suele haber uno por aplicación y es el único punto de contacto con PostgreSQL (o SQLite, MySQL, etc.).
# Insertar
{:ok, usuario} = MiApp.Repo.insert(changeset)
# Actualizar
changeset_actualizado = Usuario.changeset_perfil(usuario, %{nombre: "Ana García"})
{:ok, usuario_actualizado} = MiApp.Repo.update(changeset_actualizado)
# Borrar
{:ok, _} = MiApp.Repo.delete(usuario)
# Obtener por id (lanza excepción si no existe)
usuario = MiApp.Repo.get!(Usuario, 42)
# Obtener por id (devuelve nil si no existe)
usuario = MiApp.Repo.get(Usuario, 42)
Todas las operaciones devuelven {:ok, resultado} o {:error, changeset}, lo que se integra perfectamente con el pattern matching de Elixir y los flujos de control con with.
Ecto.Query: queries componibles
El DSL de queries de Ecto se compone como funciones, lo que permite construir consultas dinámicamente sin concatenar strings SQL:
import Ecto.Query # Query básica query = from u in Usuario, where: u.activo == true, order_by: [asc: u.nombre], select: u usuarios = MiApp.Repo.all(query) # Queries componibles: la función devuelve una query que se puede extender def activos(query) do from u in query, where: u.activo == true end def con_email_verificado(query) do from u in query, where: not is_nil(u.email_verificado_el) end def ordenados_por_nombre(query) do from u in query, order_by: [asc: u.nombre] end # Componer usuarios = Usuario |> activos() |> con_email_verificado() |> ordenados_por_nombre() |> MiApp.Repo.all()
Ecto traduce esto a SQL en tiempo de compilación cuando puede, y siempre usa consultas parametrizadas, sin posibilidad de SQL injection.
Joins y preloads
# JOIN explícito query = from u in Usuario, join: e in assoc(u, :empresa), where: e.pais == "ES", preload: [empresa: e] # Preload separado (dos queries) usuarios = MiApp.Repo.all(Usuario) usuarios = MiApp.Repo.preload(usuarios, [:empresa, :articulos])
La decisión entre JOIN y preload separado es explícita y deliberada. Ecto no hace lazy loading, que es una de las fuentes de problemas de rendimiento más habituales en ORMs como ActiveRecord o Hibernate. Si quieres los datos asociados, tienes que pedirlos expresamente.
Migraciones
Ecto incluye un sistema de migraciones para gestionar el esquema de la base de datos:
defmodule MiApp.Repo.Migrations.CrearUsuarios do
use Ecto.Migration
def change do
create table(:usuarios) do
add :nombre, :string, null: false
add :email, :string, null: false
add :edad, :integer
add :activo, :boolean, default: true, null: false
add :empresa_id, references(:empresas, on_delete: :nilify_all)
timestamps()
end
create unique_index(:usuarios, [:email])
create index(:usuarios, [:empresa_id])
end
end
Las migraciones son reversibles por defecto cuando usas change/0. Ecto sabe cómo deshacer un create table o un add column. Para operaciones más complejas, puedes definir up/0 y down/0 por separado.
Transacciones y Multi
# Transacción simple
MiApp.Repo.transaction(fn ->
{:ok, usuario} = MiApp.Repo.insert(changeset_usuario)
{:ok, _} = MiApp.Repo.insert(changeset_empresa)
usuario
end)
# Ecto.Multi: operaciones nombradas con mejor manejo de errores
alias Ecto.Multi
Multi.new()
|> Multi.insert(:usuario, changeset_usuario)
|> Multi.insert(:empresa, changeset_empresa)
|> Multi.run(:notificacion, fn _repo, %{usuario: usuario} ->
Notificaciones.enviar_bienvenida(usuario)
end)
|> MiApp.Repo.transaction()
Ecto.Multi permite nombrar cada operación y acceder a los resultados anteriores. Si cualquiera falla, la transacción entera se revierte y recibes el nombre de la operación que falló junto con el error.
Para ver cómo Ecto se integra con Phoenix en una aplicación completa, el artículo sobre Phoenix LiveView de esta serie muestra el patrón completo. Si vienes de Java y conoces JPA o Hibernate, el artículo sobre Java Loom ofrece una comparativa interesante del modelo de acceso a datos.
Imagen: Pexels / Myburgh Roux
