Trilha Phoenix · Ecto
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas e
migrations como o psql mostraria, faz requisições ao endpoint, confere os
exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` (veja a seção sobre `mix ecto.create` neste notebook) ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas e os índices de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
Kino.Markdown.new("**`\\d #{tabela}`**\n\n" <> tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> lista)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil) do
descartar_respostas_antigas()
conn =
Phoenix.ConnTest.build_conn()
|> Phoenix.ConnTest.dispatch(@endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"
corpo =
case requisicao(metodo, caminho, parametros) do
{:ok, conn} ->
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()
"**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))
{:erro, erro} ->
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
Kino.Markdown.new(titulo <> "\n\n" <> corpo)
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
timestamps: %{
texto:
"o comando pediu quatro campos: `name`, `email`, `bio` e `number_of_pets`. Quantas colunas a tabela `users` vai ter depois da migration?",
opcoes: [
a: "Quatro, uma por campo pedido.",
b: "Cinco: os quatro campos e uma chave primária `id`.",
c: "Sete: os quatro campos, `id`, `inserted_at` e `updated_at`.",
d: "Depende do banco de dados."
],
correta: :c,
explicacao:
"A migration declara os quatro campos e chama `timestamps/1`, que acrescenta `inserted_at` e `updated_at`. A chave primária `id` não aparece na migration: `create table/1` a cria por padrão, como um inteiro gerado por uma sequência."
},
migrar_de_novo: %{
texto: "a migration `CreateUsers` já rodou. O que acontece se você rodar `mix ecto.migrate` de novo?",
opcoes: [
a: "O Ecto tenta criar a tabela outra vez, e o PostgreSQL responde que ela já existe.",
b: "O Ecto apaga a tabela e a cria de novo, vazia.",
c: "Nada: o Ecto consulta a tabela `schema_migrations`, vê que essa versão já rodou e não a executa.",
d: "O Ecto cria uma segunda tabela, `users_2`."
],
correta: :c,
explicacao:
"Cada migration aplicada fica registrada pela sua versão na tabela `schema_migrations`, que o próprio Ecto cria na primeira vez. Antes de rodar, o Ecto compara as migrations que conhece com as versões registradas e executa só as pendentes. É isso que torna `mix ecto.migrate` seguro de repetir."
},
changeset_vazio: %{
texto:
"o `changeset/2` do `User` chama `cast` com os quatro campos e `validate_required` com os quatro. O que tem em `User.changeset(%User{}, %{})`?",
opcoes: [
a: "Um changeset válido sem mudanças, porque nada foi alterado.",
b: "Um changeset inválido, com um erro `can't be blank` para cada um dos quatro campos.",
c: "Uma exceção, porque os parâmetros estão vazios.",
d: "Um changeset inválido com um só erro, o do primeiro campo."
],
correta: :b,
explicacao:
"`validate_required/3` não para no primeiro problema: verifica todos os campos da lista e acumula um erro para cada um ausente. A função não levanta exceção; ela devolve o changeset com `valid?: false` e a lista `errors`. Quem decide o que fazer com isso é quem chamou."
},
random_key: %{
texto:
"os parâmetros têm os quatro campos válidos e uma chave a mais, `random_key: \"random value\"`, que o schema não conhece. O que o `cast/3` faz com ela?",
opcoes: [
a: "Levanta um erro dizendo que a chave não existe no schema.",
b: "Acrescenta um erro ao changeset e o marca como inválido.",
c: "Grava a chave junto, e o banco a recusa ao inserir.",
d: "Descarta a chave em silêncio: só os campos da lista do `cast` entram em `changes`."
],
correta: :d,
explicacao:
"`cast/3` só considera os campos passados na lista, e só se existirem no schema. Todo o resto é ignorado. É uma proteção: um formulário adulterado não consegue alterar campos que o código não liberou, como um `admin` ou um `preco`."
},
valores_string: %{
texto:
"um formulário envia `%{\"name\" => \"Ana\", \"number_of_pets\" => \"3\"}`, com chaves e valores em texto, como todo parâmetro que chega pela web. O que fica em `changeset.changes`?",
opcoes: [
a: "`%{\"name\" => \"Ana\", \"number_of_pets\" => \"3\"}`, exatamente como chegou.",
b: "`%{name: \"Ana\", number_of_pets: 3}`: chaves como átomos e o número convertido para inteiro.",
c: "`%{name: \"Ana\", number_of_pets: \"3\"}`: só as chaves são convertidas.",
d: "Nada: `cast/3` só aceita chaves que são átomos."
],
correta: :b,
explicacao:
"`cast/3` aceita chaves em átomo ou em string (mas não as duas misturadas) e converte cada valor para o tipo declarado no schema. `\"3\"` vira `3` porque `number_of_pets` é `:integer`. Um valor que não pode ser convertido, como `\"muitos\"`, vira um erro `is invalid` no changeset."
},
struct_sem_validacao: %{
texto:
"o `changeset/2` exige `name`, `email` e `bio`. O que acontece com `Repo.insert(%User{email: \"user1@example.com\"})`, sem changeset?",
opcoes: [
a: "Devolve `{:error, changeset}` com os erros de `name` e `bio`.",
b: "Insere o registro, com `name` e `bio` vazios.",
c: "Levanta uma exceção, porque o Repo só aceita changesets.",
d: "Chama `User.changeset/2` automaticamente antes de inserir."
],
correta: :b,
explicacao:
"As validações moram no changeset, e o changeset só roda se você o chamar. Uma struct passada direto ao Repo é inserida como está, e o banco aceita porque as colunas `name` e `bio` permitem `NULL`. Por isso dados vindos de fora (formulários, APIs) sempre passam por um changeset."
},
drop_conectado: %{
texto: "o `Hello.Repo` está rodando, com 10 conexões abertas no banco `trilha_phoenix_dev`. O que acontece se você mandar apagar o banco agora?",
opcoes: [
a: "O banco é apagado, e as próximas consultas do Repo dão erro.",
b: "O Ecto fecha as conexões do Repo, apaga o banco e reconecta.",
c: "O PostgreSQL recusa: não apaga um banco com sessões abertas.",
d: "O PostgreSQL apaga só as tabelas e mantém o banco."
],
correta: :c,
explicacao:
"`DROP DATABASE` falha com o erro `55006 (object_in_use)` enquanto houver outras sessões conectadas ao banco. Por isso `mix ecto.drop` costuma ser rodado com o servidor parado. A alternativa é `mix ecto.drop --force-drop`, que pede ao PostgreSQL (13 ou mais novo) para derrubar as conexões antes."
},
rollback: %{
texto: "a migration `AddCommentsTable` só tem a função `change/0`, com um `create table(:comments)`. O que `mix ecto.rollback` faz com ela?",
opcoes: [
a: "Nada, porque não existe uma função `down/0`.",
b: "Apaga a tabela `comments`: o Ecto sabe inverter um `create table`.",
c: "Esvazia a tabela `comments`, mas mantém a estrutura.",
d: "Apaga o arquivo da migration."
],
correta: :b,
explicacao:
"Em `change/0`, o Ecto registra os comandos e sabe executá-los nos dois sentidos: indo, `create table` cria; voltando, vira `drop table`. Para comandos que não têm inverso conhecido, como um `execute` com SQL livre, a migration precisa definir `up/0` e `down/0` separadamente."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
opcional: ~S'''
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio])
end
''',
validacoes: ~S'''
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
|> validate_format(:email, ~r/@/)
end
''',
consultas: ~S'''
defmodule Hello.Consultas do
import Ecto.Query
alias Hello.{Repo, User}
def emails_com_pets do
Repo.all(
from u in User,
where: u.number_of_pets > 0,
order_by: u.email,
select: u.email
)
end
def contar_emails_com(trecho) do
Repo.one(from u in User, where: ilike(u.email, ^"%#{trecho}%"), select: count(u.id))
end
end
''',
unico: ~S'''
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
end
''',
comments: ~S'''
defmodule Hello.Repo.Migrations.AddCommentsTable do
use Ecto.Migration
def change do
create table(:comments) do
add :body, :string
add :word_count, :integer
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)
''',
brecho: ~S'''
# priv/repo/migrations/20250320000000_create_produtos.exs
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
# lib/hello/produto.ex
defmodule Hello.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos, :vendido])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
# Depois: Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# No router, dentro do scope "/":
get "/produtos", ProdutoController, :index
post "/produtos", ProdutoController, :create
# lib/hello_web/controllers/produto_controller.ex e produto_html.ex
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
import Ecto.Query
alias Hello.{Produto, Repo}
def index(conn, _params) do
produtos =
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
render(conn, :index, produtos: produtos)
end
def create(conn, %{"produto" => attrs}) do
case Repo.insert(Produto.changeset(%Produto{}, attrs)) do
{:ok, produto} ->
conn |> put_status(201) |> json(%{id: produto.id})
{:error, changeset} ->
erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
conn |> put_status(422) |> json(%{errors: erros})
end
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h2>Vitrine do Brechó</h2>
<ul>
<li :for={produto <- @produtos}>{produto.nome} · {produto.preco_centavos} centavos</li>
</ul>
</Layouts.app>
"""
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
opcional: [
{"`User.changeset(%User{}, %{})` acusa `name`, `email` e `bio` em branco.",
fn -> campos_com_erro(%{}, [:name, :email, :bio]) end},
{"`number_of_pets` deixou de ser obrigatório.",
fn ->
if Keyword.has_key?(changeset_user(%{}).errors, :number_of_pets),
do: {:erro, "O changeset ainda acusa `number_of_pets`. Tire o campo da lista do `validate_required`."},
else: :ok
end},
{"`number_of_pets` continua sendo aceito pelo `cast`.",
fn ->
case changeset_user(%{"number_of_pets" => "3"}).changes do
%{number_of_pets: 3} -> :ok
_ -> {:erro, "O campo deve continuar na lista do `cast/3`; só sai da lista do `validate_required`."}
end
end}
],
validacoes: [
{"Uma `bio` de uma letra recebe o erro de tamanho mínimo 2.",
fn -> erro_validacao(%{bio: "A"}, :bio, validation: :length, kind: :min, count: 2) end},
{"Uma `bio` de 141 letras recebe o erro de tamanho máximo 140.",
fn -> erro_validacao(%{bio: String.duplicate("a", 141)}, :bio, validation: :length, kind: :max, count: 140) end},
{"Um `email` sem `@` recebe o erro de formato.",
fn -> erro_validacao(%{email: "example.com"}, :email, validation: :format) end},
{"Um usuário completo, sem `number_of_pets`, é válido.",
fn ->
cs = changeset_user(%{name: "Joe", email: "joe@example.com", bio: "An example to all"})
if cs.valid?,
do: :ok,
else: {:erro, "O changeset ficou inválido: `#{inspect(cs.errors)}`. `number_of_pets` deve continuar opcional."}
end}
],
consultas: [
{"`Hello.Consultas` define `emails_com_pets/0` e `contar_emails_com/1`.",
fn -> exporta(Hello.Consultas, emails_com_pets: 0, contar_emails_com: 1) end},
{"`emails_com_pets/0` devolve os e-mails de quem tem pelo menos um pet, em ordem alfabética.",
fn ->
esperado = coluna("SELECT email FROM users WHERE number_of_pets > 0 ORDER BY email")
obtido = apply(Hello.Consultas, :emails_com_pets, [])
cond do
obtido == esperado -> :ok
is_list(obtido) and Enum.any?(obtido, &is_struct/1) -> {:erro, "A função devolveu structs. Use `select: u.email` para devolver só os e-mails."}
true -> {:erro, "Esperado `#{inspect(esperado)}`, veio `#{inspect(obtido, limit: 10)}`."}
end
end},
{"`contar_emails_com/1` conta os e-mails que contêm o trecho, sem diferenciar maiúsculas.",
fn ->
Enum.reduce_while(["brecho", "EXAMPLE", "a", "nada-disso"], :ok, fn trecho, :ok ->
[[esperado]] = sql("SELECT count(*) FROM users WHERE email ILIKE $1", ["%" <> trecho <> "%"]).rows
obtido = apply(Hello.Consultas, :contar_emails_com, [trecho])
if obtido == esperado,
do: {:cont, :ok},
else: {:halt, {:erro, "Para `#{inspect(trecho)}`, o esperado é `#{esperado}`, veio `#{inspect(obtido)}`."}}
end)
end}
],
unico: [
{"O índice único `users_email_index` existe.",
fn ->
if coluna("SELECT indexname FROM pg_indexes WHERE indexname = 'users_email_index'") == ["users_email_index"],
do: :ok,
else: {:erro, "Rode a célula da migration `AddUsersEmailIndex` e a do `mix ecto.migrate` logo abaixo dela."}
end},
{"Inserir um e-mail repetido devolve `{:error, changeset}` com o erro `has already been taken`.",
fn ->
params = %{name: "Repetida", email: "repetida@example.com", bio: "Duas vezes"}
{:error, resultado} =
apply(@repo, :transaction, [
fn ->
primeiro = apply(@repo, :insert, [changeset_user(params)])
segundo = apply(@repo, :insert, [changeset_user(params)])
apply(@repo, :rollback, [{primeiro, segundo}])
end
])
case resultado do
{{:ok, _}, {:error, %Ecto.Changeset{errors: [email: {"has already been taken", _}]}}} -> :ok
{{:ok, _}, outro} -> {:erro, "O segundo insert devolveu `#{inspect(outro, limit: 8)}`."}
outro -> {:erro, "O primeiro insert falhou: `#{inspect(outro, limit: 8)}`."}
end
end}
],
comments: [
{"A migration `Hello.Repo.Migrations.AddCommentsTable` está registrada com a versão `20250318001628`.",
fn ->
if {20250318001628, Hello.Repo.Migrations.AddCommentsTable} in migracoes(),
do: :ok,
else: {:erro, "Defina o módulo e chame `Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)` na mesma célula."}
end},
{"A tabela `comments` existe, com `body`, `word_count` e os timestamps.",
fn -> colunas("comments", ~w(id body word_count inserted_at updated_at)) end},
{"`body` é texto e `word_count` é inteiro.",
fn ->
tipos = sql("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'comments'").rows |> Map.new(fn [c, t] -> {c, t} end)
if tipos["body"] == "character varying" and tipos["word_count"] == "integer",
do: :ok,
else: {:erro, "Os tipos estão `#{inspect(tipos)}`. Use `:string` para `body` e `:integer` para `word_count`."}
end}
],
brecho: [
{"A tabela `produtos` existe, com `nome`, `preco_centavos`, `vendido` e os timestamps.",
fn -> colunas("produtos", ~w(nome preco_centavos vendido inserted_at updated_at)) end},
{"`Hello.Produto.changeset/2` exige `nome` e `preco_centavos`.",
fn ->
erros = changeset_produto(%{}).errors
if Keyword.has_key?(erros, :nome) and Keyword.has_key?(erros, :preco_centavos),
do: :ok,
else: {:erro, "Com parâmetros vazios, os erros foram `#{inspect(erros)}`."}
end},
{"Um preço igual a zero é recusado.",
fn ->
case changeset_produto(%{"nome" => "Camisa", "preco_centavos" => "0"}).errors[:preco_centavos] do
{_, opcoes} -> if opcoes[:validation] == :number, do: :ok, else: {:erro, "O erro de `preco_centavos` deveria vir de `validate_number/3`."}
nil -> {:erro, "O changeset aceitou `preco_centavos` igual a 0. Use `validate_number/3` com `greater_than: 0`."}
end
end},
{"Um produto novo tem `vendido` igual a `false`.",
fn ->
if struct(Hello.Produto).vendido == false,
do: :ok,
else: {:erro, "Declare o campo com `default: false` no schema."}
end},
{"`POST /produtos` com dados válidos responde 201 e grava o produto.",
fn ->
limpando(fn ->
case requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Válido", "preco_centavos" => "4500"}}) do
{:ok, %{status: 201}} ->
if coluna("SELECT nome FROM produtos WHERE nome = 'Conferência Válido' AND preco_centavos = 4500 AND NOT vendido") == ["Conferência Válido"],
do: :ok,
else: {:erro, "A resposta foi 201, mas o produto não está na tabela com preço 4500 e `vendido` falso."}
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`. Os dados chegam em `params[\"produto\"]`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"`POST /produtos` com preço inválido responde 422, lista o erro e não grava nada.",
fn ->
limpando(fn ->
case requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Inválido", "preco_centavos" => "0"}}) do
{:ok, %{status: 422} = conn} ->
cond do
not (to_string(conn.resp_body) =~ "preco_centavos") ->
{:erro, "O corpo da resposta deveria citar o campo `preco_centavos`. Veio: `#{conn.resp_body}`."}
coluna("SELECT nome FROM produtos WHERE nome = 'Conferência Inválido'") != [] ->
{:erro, "O produto inválido foi gravado."}
true ->
:ok
end
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`. Use `put_status(422)` quando o insert devolver `{:error, changeset}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"`GET /produtos` lista só os não vendidos, do mais barato ao mais caro.",
fn ->
limpando(fn ->
for {nome, preco, vendido} <- [{"Conferência Caro", 9000, false}, {"Conferência Vendido", 100, true}, {"Conferência Barato", 1500, false}] do
sql(
"INSERT INTO produtos (nome, preco_centavos, vendido, inserted_at, updated_at) VALUES ($1, $2, $3, now(), now())",
[nome, preco, vendido]
)
end
case requisicao(:get, "/produtos") do
{:ok, %{status: 200, resp_body: corpo}} ->
corpo = to_string(corpo)
barato = :binary.match(corpo, "Conferência Barato")
caro = :binary.match(corpo, "Conferência Caro")
cond do
barato == :nomatch or caro == :nomatch -> {:erro, "A página não mostra os produtos não vendidos."}
corpo =~ "Conferência Vendido" -> {:erro, "A página mostra um produto vendido. Filtre com `where: not p.vendido`."}
elem(barato, 0) > elem(caro, 0) -> {:erro, "O mais caro aparece antes do mais barato. Ordene por `preco_centavos`."}
true -> :ok
end
{:ok, conn} ->
{:erro, "A resposta veio com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end}
]
}
end
defp changeset_user(params), do: apply(Hello.User, :changeset, [struct(Hello.User), params])
defp changeset_produto(params), do: apply(Hello.Produto, :changeset, [struct(Hello.Produto), params])
defp campos_com_erro(params, campos) do
erros = changeset_user(params).errors
case Enum.reject(campos, &Keyword.has_key?(erros, &1)) do
[] -> :ok
faltando -> {:erro, "O changeset não acusou #{inspect(faltando)}. Os erros foram `#{inspect(erros)}`."}
end
end
defp erro_validacao(params, campo, esperado) do
case changeset_user(params).errors[campo] do
{_mensagem, opcoes} ->
if Enum.all?(esperado, fn {chave, valor} -> opcoes[chave] == valor end),
do: :ok,
else: {:erro, "O erro de `#{campo}` veio com `#{inspect(opcoes)}`; o esperado inclui `#{inspect(esperado)}`."}
nil ->
{:erro, "O changeset não acusou erro em `#{campo}`. Falta a validação no `changeset/2`."}
end
end
defp exporta(modulo, funcoes) do
faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)
if faltando == [],
do: :ok,
else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
end
defp colunas(tabela, esperadas) do
existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])
cond do
existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode o `mix ecto.migrate`."}
(faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
true -> :ok
end
end
defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp limpando(teste) do
teste.()
after
if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."
true ->
"`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
"O banco recusou o insert pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
end
defp explicar(%Phoenix.ActionClauseError{}) do
"Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros. Os dados do formulário chegam dentro de `params[\"produto\"]`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Ecto da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção, e termina com o guia curto Swapping Databases. Até aqui a aplicação Hello só respondia a requisições. Agora ela ganha um banco de dados: um PostgreSQL de verdade, que precisa estar rodando na sua máquina.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. Neste guia há uma fonte nova de informação: o log do Ecto. Cada consulta ao banco aparece na saída da célula como uma linha [debug] QUERY OK, seguida do SQL que o Ecto gerou. Leia esse SQL: é a melhor forma de entender o que cada função faz.
O guia usa três ferramentas de terminal que um notebook não tem: mix phx.gen.schema, as tarefas mix ecto.* e o psql. Cada uma ganha aqui um equivalente que chama as mesmas funções do Ecto por baixo, e a seção "No seu projeto" volta ao terminal.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo. Este notebook instala três pacotes a mais que os anteriores (ecto_sql, postgrex e phoenix_ecto); a primeira vez leva alguns minutos.
A aplicação Hello dentro do notebook
Um projeto gerado por mix phx.new hello já vem com Ecto e PostgreSQL. As duas primeiras células desta seção são as peças novas: a configuração do banco e o módulo Hello.Repo. As outras repetem a aplicação web do primeiro notebook, que só volta a ser usada no desafio do fim.
config/dev.exs: a configuração do banco no ambiente de desenvolvimento. Os valores padrão são os de um projeto novo: usuário postgres, senha postgres, servidor na própria máquina. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
Uma diferença em relação ao projeto: lá o banco se chamaria hello_dev. Aqui ele se chama trilha_phoenix_dev, para que o notebook nunca apague nem altere o banco de um projeto hello que você tenha criado.
lib/hello/repo.ex: o repositório, a interface do Ecto com o banco. A seção "Configuração do Repo" volta a ele.
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex, router.ex e endpoint.ex: a parte web, como no fim do primeiro notebook. O router e o endpoint vêm antes dos controllers porque o ~p confere cada caminho no router durante a compilação.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.HTML
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/layouts.ex e lib/hello_web/controllers/: os layouts, as páginas de erro e a página inicial.
defmodule HelloWeb.Layouts do
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
<header>
<strong>Hello</strong> · layout da aplicação
</header>
<main>
{render_slot(@inner_block)}
</main>
"""
end
end
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
Antes de começar: o PostgreSQL
O Ecto precisa de um servidor PostgreSQL rodando e de um usuário que possa entrar com a senha configurada acima. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem do Postgrex em uma dica. Os comandos para instalar, iniciar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Com a conexão funcionando, crie o banco da trilha. Esta célula faz o mesmo que mix ecto.create, que aparece no fim do guia:
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
O resultado é :ok na primeira vez e {:error, :already_up} nas seguintes, quando o banco já existe. As duas respostas servem. Se a verificação acima falhou, esta célula também falha, com uma resposta pouco útil, {:error, "killed"}; o motivo real está no log de erro logo acima dela.
lib/hello/application.ex: em um projeto, a árvore de supervisão inicia o Hello.Repo e o HelloWeb.Endpoint quando a aplicação sobe. O Repo vem primeiro, porque o resto da aplicação depende do banco.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Por fim, comece do zero. A célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id e os resultados das consultas sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Trilha.relacoes()
Usando o phx.gen.schema
Na documentação: Using phx.gen.schema
O Phoenix usa o Ecto para conversar com PostgreSQL, MySQL, MSSQL, SQLite3 e outros. Um projeto novo vem com o adaptador do PostgreSQL; mix phx.new aceita --database para escolher outro, ou --no-ecto para não incluir o Ecto.
A forma mais fácil de começar é gerar um schema com mix phx.gen.schema. Um schema descreve como os tipos de dados do Elixir correspondem aos de uma fonte externa, como uma tabela do banco. O guia gera um User com quatro campos:
$ mix phx.gen.schema User users name:string email:string \
bio:string number_of_pets:integer
* creating ./lib/hello/user.ex
* creating priv/repo/migrations/20170523151118_create_users.exs
Os argumentos são o nome do módulo (User), o nome da tabela (users) e os campos com seus tipos. O gerador cria dois arquivos: o schema e uma migration, o arquivo que cria a tabela no banco. O nome do arquivo da migration começa com a data e a hora em que foi gerado; esse número é a versão da migration.
Trilha.pergunta(:timestamps)
priv/repo/migrations/20250317170448_create_users.exs: a migration gerada. A versão aqui é a que o guia mostra mais adiante, na tabela schema_migrations. Como um notebook não tem arquivos, a última linha registra a versão que, no projeto, viria do nome do arquivo.
defmodule Hello.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
add :bio, :string
add :number_of_pets, :integer
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250317170448, Hello.Repo.Migrations.CreateUsers)
Além das colunas pedidas, a migration chama timestamps/1, que acrescenta inserted_at e updated_at. O gerador não executa a migration; ele avisa que é preciso rodar mix ecto.migrate. A célula abaixo é esse comando: Ecto.Migrator.run/4 recebe o Repo, a lista de migrations conhecidas, a direção (:up) e a opção all: true, que roda todas as pendentes.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
O log mostra o que aconteceu:
[info] == Running 20250317170448 Hello.Repo.Migrations.CreateUsers.change/0 forward
[info] create table users
[info] == Migrated 20250317170448 in 0.0s
O guia mostra as mesmas linhas sem o número da versão; as versões recentes do Ecto passaram a incluí-lo. O valor devolvido, [20250317170448], é a lista de versões que rodaram.
Trilha.pergunta(:migrar_de_novo)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Desta vez o log diz Migrations already up, e a lista devolvida é vazia.
Agora olhe o banco. No terminal, o guia entra no psql e usa o comando \d, que lista as tabelas e sequências. Trilha.relacoes() mostra o mesmo:
Trilha.relacoes()
São três relações. users é a tabela da migration. schema_migrations foi criada pelo próprio Ecto para registrar as migrations que já rodaram. users_id_seq é uma sequência, o contador do PostgreSQL que gera os valores da chave primária id.
\d users mostra a estrutura da tabela:
Trilha.descrever("users")
Compare com a migration:
idnão está na migration, mas existe:create table/1cria uma chave primáriabigintpor padrão, com valor padrãonextval('users_id_seq'::regclass), ou seja, o próximo número da sequência.- Cada campo
:stringviroucharacter varying(255), um texto de até 255 caracteres.:integervirouinteger. timestamps(type: :utc_datetime)criouinserted_ateupdated_atcomotimestamp(0) without time zone, sem frações de segundo e obrigatórios (not null). O PostgreSQL guarda a data sem fuso; o Ecto sabe que ela está em UTC.- As outras colunas aceitam valor vazio (
NULL): a coluna Nullable está em branco. Guarde esse detalhe para a seção de persistência.
Configuração do Repo
Na documentação: Repo configuration
O módulo Hello.Repo é a base para trabalhar com bancos de dados em uma aplicação Phoenix. Releia a célula dele, na seção da aplicação: ele só chama use Ecto.Repo com duas opções. Isso faz três coisas:
- traz para
Hello.Repotodas as funções de consulta e escrita deEcto.Repo(insert,all,one,update,deletee outras); - liga o Repo à aplicação
:hello(otp_app), que é onde ele procura a própria configuração; - escolhe o adaptador do banco,
Ecto.Adapters.Postgres.
A configuração em si fica em config/dev.exs, na célula do início. O projeto tem blocos parecidos em config/test.exs (o banco dos testes) e em config/runtime.exs (produção, com valores lidos de variáveis de ambiente). Veja a configuração completa que o Repo enxerga, já com os valores que o Ecto acrescenta:
Hello.Repo.config()
A senha aparece em texto puro: este é o ambiente de desenvolvimento, na sua máquina. show_sensitive_data_on_connection_error: true tem a mesma intenção: mostrar os dados da conexão nas mensagens de erro, o que ajuda a depurar e seria um problema em produção.
O schema
Na documentação: The schema
Os schemas do Ecto mapeiam valores do Elixir para fontes de dados externas e os dados externos de volta para estruturas do Elixir. Também podem definir relações com outros schemas: um User pode ter vários posts, e cada post pertence a um usuário. Isso aparece nos guias de contextos.
lib/hello/user.ex: o schema que o gerador criou.
defmodule Hello.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :bio, :string
field :email, :string
field :name, :string
field :number_of_pets, :integer
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
end
end
No fundo, um schema do Ecto é uma struct do Elixir. O bloco schema diz ao Ecto como converter os campos da struct %User{} de e para a tabela users.
%Hello.User{}
A struct tem os quatro campos, o id, os timestamps e um campo __meta__, que o Ecto usa para saber de onde o dado veio. :built quer dizer que a struct foi montada no código e ainda não existe no banco.
O schema também se descreve. As funções __schema__/1 e __schema__/2 são o que o Ecto consulta para gerar o SQL:
%{
tabela: Hello.User.__schema__(:source),
campos: Hello.User.__schema__(:fields),
tipo_de_number_of_pets: Hello.User.__schema__(:type, :number_of_pets),
tipo_de_inserted_at: Hello.User.__schema__(:type, :inserted_at)
}
Converter dados de e para o banco quase nunca basta: é preciso validar o que chega. Para isso existem os changesets.
Changesets e validações
Na documentação: Changesets and validations
Um changeset define uma sequência de transformações pelas quais um dado passa antes de estar pronto para uso: conversão de tipos, validação da entrada do usuário, descarte de parâmetros que não interessam. Os repositórios do Ecto entendem changesets: recusam dados inválidos e, ao atualizar, gravam só os campos que mudaram.
A função changeset/2 gerada tem duas etapas:
cast/3recebe a struct, os parâmetros externos (as mudanças propostas) e a lista dos campos que podem ser alterados. Só aceita campos que existem no schema.validate_required/3verifica se os campos da lista estão presentes no changeset que ocast/3devolveu. O gerador marca todos como obrigatórios.
É do lado do servidor o que você faria com required e pattern em um <input>, com uma diferença: a validação do navegador pode ser desligada por quem usa a página, e a do servidor não.
Trilha.pergunta(:changeset_vazio)
O guia faz os testes no IEx. Aqui, cada célula é um passo:
changeset = Hello.User.changeset(%Hello.User{}, %{})
%{valido?: changeset.valid?, erros: changeset.errors}
Cada erro é uma tupla com a mensagem ("can't be blank") e uma lista de metadados (validation: :required) que diz qual validação falhou.
Sua vez: um campo opcional
O guia torna number_of_pets opcional. Na célula do Hello.User, tire :number_of_pets da lista do validate_required (e só dela: o campo continua no cast). Avalie a célula. No IEx, o guia chama recompile(); aqui, avaliar a célula tem o mesmo efeito.
Hello.User.changeset(%Hello.User{}, %{}).errors
Trilha.conferir(:opcional)
Trilha.solucao(:opcional)
Parâmetros que o schema não conhece
Trilha.pergunta(:random_key)
params = %{
name: "Joe Example",
email: "joe@example.com",
bio: "An example to all",
number_of_pets: 5,
random_key: "random value"
}
changeset = Hello.User.changeset(%Hello.User{}, params)
O changeset é válido, e changes tem só os quatro campos do schema:
changeset.changes
Changesets transformam dados externos, como a entrada de um formulário ou as linhas de um CSV, em dados válidos para o sistema. Parâmetros desconhecidos são descartados, e dados que não podem ser convertidos para o tipo do schema aparecem nos erros.
Parâmetros como chegam da web
O guia usa chaves em átomo, como name:. Mas o primeiro notebook mostrou que, em uma requisição, params tem chaves e valores em string. É assim que um formulário chega ao changeset.
Trilha.pergunta(:valores_string)
Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", "number_of_pets" => "3"}).changes
O cast/3 converteu as chaves e o valor. Agora um valor que não vira inteiro:
Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", "number_of_pets" => "muitos"}).errors
number_of_pets ganhou o erro {"is invalid", [type: :integer, validation: :cast]}. Não houve exceção: o valor estranho virou um erro de validação, que pode ser mostrado ao usuário ao lado do campo.
Só uma coisa o cast/3 não aceita: chaves em átomo e em string misturadas no mesmo mapa.
Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", bio: "Oi"})
A exceção é Ecto.CastError: expected params to be a map with atoms or string keys, got a map with mixed keys. Isso costuma acontecer quando o código acrescenta uma chave em átomo a um params que veio da requisição.
Sua vez: mais validações
Além de exigir campos, um changeset pode validar formato e tamanho. O guia acrescenta três validações ao changeset/2:
- a
bioprecisa ter pelo menos 2 caracteres:validate_length(:bio, min: 2); - e no máximo 140:
validate_length(:bio, max: 140); - o
emailprecisa ter um@:validate_format(:email, ~r/@/).
Acrescente as três ao fim do pipeline, na célula do Hello.User. Os trechos do guia nesta parte voltam a incluir :number_of_pets no validate_required; aqui ele continua opcional, como você deixou.
Trilha.conferir(:validacoes)
Trilha.solucao(:validacoes)
Veja os erros que as validações novas produzem:
%{
bio: Hello.User.changeset(%Hello.User{}, %{bio: "A"}).errors[:bio],
email: Hello.User.changeset(%Hello.User{}, %{email: "example.com"}).errors[:email]
}
A mensagem de bio é "should be at least %{count} character(s)", com count: 2 nos metadados. O %{count} fica sem substituir de propósito: assim a mensagem pode ser traduzida antes de receber o número. Na hora de mostrar, Ecto.Changeset.traverse_errors/2 percorre todos os erros e aplica a função que você passar, aqui uma que substitui cada %{chave} pelo valor:
changeset = Hello.User.changeset(%Hello.User{}, %{bio: "A", email: "example.com"})
Ecto.Changeset.traverse_errors(changeset, fn {mensagem, opcoes} ->
Enum.reduce(opcoes, mensagem, fn {chave, valor}, acc ->
String.replace(acc, "%{#{chave}}", to_string(valor))
end)
end)
Em um projeto, os CoreComponents gerados fazem essa substituição (com tradução, pelo Gettext) quando mostram os erros de um formulário. A documentação de Ecto.Changeset lista as outras validações.
Persistência dos dados
Na documentação: Data persistence
Os repositórios do Ecto são a interface com um sistema de armazenamento, seja um banco como o PostgreSQL, seja um serviço externo. O Repo cuida dos detalhes: a comunicação com o adaptador, o pool de conexões e a tradução de violações de restrições do banco em erros. Quem chama só se preocupa em buscar e gravar dados.
Trilha.pergunta(:struct_sem_validacao)
Hello.Repo.insert(%Hello.User{email: "user1@example.com"})
Hello.Repo.insert(%Hello.User{email: "user2@example.com"})
Leia o log de cada inserção. Ele mostra o SQL que o Ecto gerou e os valores enviados separadamente, como parâmetros ($1, $2, $3):
[debug] QUERY OK source="users" db=0.5ms queue=0.6ms idle=112.6ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user1@example.com", ~U[...], ~U[...]]
Só entram as colunas com valor: email e os timestamps, que o Ecto preencheu sozinho. O RETURNING "id" pede ao banco o id gerado pela sequência, que volta na struct. O __meta__ agora diz :loaded: a struct corresponde a uma linha do banco. A linha ↳ abaixo do SQL aponta o código que fez a consulta, por causa do stacktrace: true da configuração.
Com um changeset, o Repo valida antes. Se o changeset é inválido, nada vai ao banco e a resposta é {:error, changeset}:
Hello.Repo.insert(Hello.User.changeset(%Hello.User{}, %{email: "user3@example.com"}))
Não há log de consulta, e o changeset devolvido tem action: :insert, que registra qual operação foi tentada.
Lendo os dados
Repo.all/1 recebe uma fonte de dados, aqui o schema User, e a traduz para uma consulta SQL. Depois converte cada linha em uma struct %User{}:
Hello.Repo.all(Hello.User)
Para consultas mais elaboradas, o Ecto tem uma linguagem própria, a Ecto.Query, com proteção contra injeção de SQL e otimização em tempo de compilação. import Ecto.Query traz a macro from/2. As células de consulta repetem o import para poderem ser avaliadas sozinhas.
import Ecto.Query
Hello.Repo.all(from u in Hello.User, select: u.email)
O SQL gerado é SELECT u0."email" FROM "users" AS u0: só a coluna pedida. Repo.one/2 busca um único resultado; aqui, a quantidade de usuários cujo e-mail contém 1:
import Ecto.Query
Hello.Repo.one(from u in Hello.User, where: ilike(u.email, "%1%"), select: count(u.id))
ilike é a comparação do PostgreSQL que ignora maiúsculas e minúsculas. O select pode montar qualquer estrutura, como um mapa de id para e-mail:
import Ecto.Query
Hello.Repo.all(from u in Hello.User, select: %{u.id => u.email})
Atualizando e apagando
Além de inserir, Repo.update/2 e Repo.delete/2 alteram e apagam um registro. update recebe um changeset; para mudanças feitas pelo próprio código, sem validação, Ecto.Changeset.change/2 monta um:
user = Hello.Repo.get!(Hello.User, 1)
Hello.Repo.update(Ecto.Changeset.change(user, name: "User One"))
O UPDATE do log só tem as colunas que mudaram: name e updated_at.
user = Hello.Repo.get!(Hello.User, 2)
Hello.Repo.delete(user)
Operações em lote
Repo.insert_all/3, Repo.update_all/3 e Repo.delete_all/2 agem sobre várias linhas com um só comando SQL. Elas não passam pelo schema nem por changesets: não validam e não preenchem os timestamps.
Hello.Repo.insert_all(Hello.User, [%{email: "sem-data@example.com"}])
A exceção vem do próprio PostgreSQL: null value in column "inserted_at" of relation "users" violates not-null constraint. Os timestamps são obrigatórios na tabela, e insert_all não os preenche. Com eles, funciona. A célula abaixo insere os usuários dos próximos exercícios:
agora = DateTime.utc_now(:second)
Hello.Repo.insert_all(Hello.User, [
%{name: "Ana", email: "ana@brecho.com", bio: "Vende livros", number_of_pets: 2, inserted_at: agora, updated_at: agora},
%{name: "Bia", email: "bia@example.com", bio: "Compra discos", number_of_pets: 0, inserted_at: agora, updated_at: agora},
%{name: "Caio", email: "caio@brecho.com", bio: "Vende roupas", number_of_pets: 1, inserted_at: agora, updated_at: agora},
%{name: "Duda", email: "duda@example.com", bio: "Compra de tudo", inserted_at: agora, updated_at: agora},
%{name: "Edu", email: "edu@Example.com", bio: "Só olha", number_of_pets: 3, inserted_at: agora, updated_at: agora}
])
O resultado {5, nil} é a quantidade de linhas inseridas. update_all recebe uma consulta e o que mudar; aqui, quem não informou pets passa a ter zero:
import Ecto.Query
from(u in Hello.User, where: is_nil(u.number_of_pets))
|> Hello.Repo.update_all(set: [number_of_pets: 0])
Sua vez: consultas
Escreva o módulo lib/hello/consultas.ex com duas funções:
emails_com_pets/0: os e-mails dos usuários com pelo menos um pet, em ordem alfabética. Usewhere,order_byeselect.contar_emails_com/1: recebe um trecho de texto e devolve quantos usuários têm esse trecho no e-mail, sem diferenciar maiúsculas e minúsculas.
Na segunda, o trecho é um valor de fora da consulta. Para usá-lo dentro de from, escreva ^ antes da expressão: ilike(u.email, ^"%#{trecho}%"). O ^ diz ao Ecto que aquilo é um valor a enviar como parâmetro, e não código da consulta. É isso que protege contra injeção de SQL.
defmodule Hello.Consultas do
import Ecto.Query
# Defina aqui emails_com_pets/0 e contar_emails_com/1.
end
Trilha.conferir(:consultas)
Trilha.solucao(:consultas)
Erro como conteúdo: um e-mail repetido
Nada impede, até agora, dois usuários com o mesmo e-mail. O lugar certo para essa regra é o banco: um índice único. A migration abaixo o cria.
defmodule Hello.Repo.Migrations.AddUsersEmailIndex do
use Ecto.Migration
def change do
create unique_index(:users, [:email])
end
end
Trilha.migracao(20250317180000, Hello.Repo.Migrations.AddUsersEmailIndex)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
O índice se chama users_email_index. Se a migration falhar com could not create unique index, o banco já tem e-mails repetidos, provavelmente porque alguma célula de inserção foi avaliada duas vezes. Avalie Trilha.recomecar() e refaça as células desde a migration CreateUsers.
Agora tente inserir duas vezes o mesmo e-mail, por um changeset válido:
params = %{name: "Ana", email: "ana@brecho.com", bio: "Outra Ana"}
Hello.Repo.insert(Hello.User.changeset(%Hello.User{}, params))
O log mostra QUERY ERROR, e a célula termina em Ecto.ConstraintError. O changeset era válido: quem recusou foi o banco. A mensagem da exceção explica o que fazer: chamar unique_constraint/3 no changeset.
Sua vez: a restrição no changeset
Acrescente unique_constraint(:email) ao fim do changeset/2 do Hello.User e avalie a célula. Depois avalie de novo a célula acima: em vez da exceção, o resultado é {:error, changeset} com o erro email: {"has already been taken", ...}, que pode ir para o formulário como qualquer outro.
unique_constraint/2 não consulta o banco antes de inserir. Ela avisa ao Ecto que, se o banco recusar o insert pelo índice users_email_index, a recusa deve virar um erro no changeset.
Trilha.conferir(:unico)
Trilha.solucao(:unico)
O guia lembra que o Ecto faz muito mais, e que a documentação do Ecto conta o resto. Os guias de modelagem de dados, os próximos da trilha, mostram como esconder esse acesso ao banco atrás de módulos que agrupam funcionalidades relacionadas.
Tarefas do Mix
Na documentação: Mix tasks
O Ecto vem com tarefas do Mix para administrar o banco. No notebook, cada uma é a função do Ecto que a tarefa chama.
mix ecto.create
Na documentação: mix ecto.create
Cria o banco configurado no Repo. Você já a usou no começo:
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
O guia lista os erros mais comuns, que vêm do PostgreSQL: o usuário postgres não existe, não pode entrar (LOGIN), não pode criar bancos (CREATEDB), ou usa outra senha. As correções são comandos SQL no psql, como administrador:
CREATE ROLE postgres LOGIN CREATEDB;
ALTER ROLE postgres LOGIN;
ALTER ROLE postgres CREATEDB;
As mensagens mudaram um pouco desde que o guia foi escrito. Veja duas reais, com uma senha errada e um usuário que não existe:
Trilha.verificar_postgres(password: "senha-errada")
Trilha.verificar_postgres(username: "usuario_que_nao_existe")
As duas dão 28P01 (invalid_password) password authentication failed. Quando o servidor pede senha, o PostgreSQL não conta se o usuário existe; a mensagem role "postgres" does not exist do guia aparece só em servidores que não pedem senha. Em algumas distribuições Linux, como o Fedora, o padrão é outro método, ident, e a mensagem é Ident authentication failed. O LEIA-ME.md mostra como resolver.
Se a aplicação tiver outro Repo, mix ecto.create -r OurCustom.Repo cria o banco dele. No notebook, seria storage_up(OurCustom.Repo.config()).
mix ecto.drop
Na documentação: mix ecto.drop
Apaga o banco do Repo, sem pedir confirmação. Cuidado com ela.
Trilha.pergunta(:drop_conectado)
Ecto.Adapters.Postgres.storage_down(Hello.Repo.config())
A resposta é {:error, "ERROR 55006 (object_in_use) database \"trilha_phoenix_dev\" is being accessed by other users\n\nThere are 10 other sessions using the database."}. As 10 sessões são o pool de conexões do Hello.Repo (pool_size: 10). No terminal, mix ecto.drop costuma rodar com o servidor parado, e o problema não aparece. Aqui, pare o Repo primeiro:
Kino.terminate_child(Process.whereis(Hello.Repo))
Ecto.Adapters.Postgres.storage_down(Hello.Repo.config())
:ok: o banco não existe mais. Recrie-o e ligue o Repo de novo:
:ok = Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
{:ok, _} = Kino.start_child(Hello.Repo)
Trilha.relacoes()
O banco está vazio. Como a estrutura inteira está nas migrations, um comando a reconstrói. Os dados não voltam: migrations guardam a estrutura, não o conteúdo.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Existe também mix ecto.drop --force-drop, que pede ao PostgreSQL (versão 13 ou mais nova) para derrubar as conexões antes de apagar. No adaptador, é a opção force_drop: true de storage_down/1.
mix ecto.gen.migration
Na documentação: mix ecto.gen.migration
Migrations são uma forma programática e repetível de alterar a estrutura do banco. Os geradores do Phoenix as criam junto com schemas e contextos; para escrever uma do zero, existe mix ecto.gen.migration, que recebe um nome em snake_case descrevendo a mudança:
$ mix ecto.gen.migration add_comments_table
* creating priv/repo/migrations
* creating priv/repo/migrations/20250318001628_add_comments_table.exs
O arquivo gerado tem um módulo com uma função change/0 vazia:
defmodule Hello.Repo.Migrations.AddCommentsTable do
use Ecto.Migration
def change do
end
end
change/0 serve para os dois sentidos: o Ecto descobre o que fazer quando a migration avança e quando é desfeita.
Sua vez: a tabela de comentários
Complete a migration do guia: uma tabela comments com uma coluna body (:string), uma coluna word_count (:integer) e os timestamps com type: :utc_datetime. A última linha da célula registra a versão.
defmodule Hello.Repo.Migrations.AddCommentsTable do
use Ecto.Migration
def change do
end
end
Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)
A referência das funções de migration está na documentação de Ecto.Migration. Para alterar uma tabela que já existe, a função é alter/2.
mix ecto.migrate
Na documentação: mix ecto.migrate
Com a migration pronta, rode as pendentes:
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Trilha.conferir(:comments)
Trilha.solucao(:comments)
Na primeira execução, o ecto.migrate cria a tabela schema_migrations, que guarda a versão (a parte numérica do nome do arquivo) de cada migration aplicada:
Trilha.consultar("SELECT * FROM schema_migrations ORDER BY version")
São três linhas, uma a mais que no guia: a migration do índice único, que este notebook acrescentou.
Por padrão, mix ecto.migrate roda todas as pendentes. Há opções para controlar quantas:
| No terminal | No Ecto.Migrator.run/4 |
O que faz |
|---|---|---|
mix ecto.migrate |
all: true |
todas as pendentes |
mix ecto.migrate -n 2 |
step: 2 |
as próximas duas |
mix ecto.migrate --step 2 |
step: 2 |
o mesmo que -n |
mix ecto.migrate --to 20250317170448 |
to: 20250317170448 |
todas até essa versão, inclusive |
mix ecto.rollback
Na documentação: mix ecto.rollback
mix ecto.rollback desfaz a última migration aplicada. É o espelho do ecto.migrate.
Trilha.pergunta(:rollback)
A direção agora é :down, e step: 1 é o padrão do mix ecto.rollback: desfazer só a última.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, step: 1)
O log diz == Running 20250318001628 Hello.Repo.Migrations.AddCommentsTable.change/0 backward e drop table comments. A linha da versão saiu de schema_migrations:
Trilha.consultar("SELECT * FROM schema_migrations ORDER BY version")
ecto.rollback aceita as mesmas opções que ecto.migrate (-n, --step, --to). Rode a migration de novo, agora só um passo:
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, step: 1)
Trilha.relacoes()
Trocando de banco de dados
Na documentação: Swapping Databases
Um projeto Phoenix usa PostgreSQL por padrão, mas o Ecto tem adaptadores para outros bancos. O guia Swapping Databases mostra a troca nos dois momentos possíveis.
Em um projeto novo (Using phx.new), basta escolher na criação: mix phx.new hello_phoenix --database mysql. As opções são postgres (o padrão), mysql, mssql e sqlite3. O gerador configura as dependências e os arquivos.
Em um projeto existente (Within an existing application), são três mudanças:
- no
mix.exs, trocar a dependência{:postgrex, ...}pelo driver do outro banco, como{:myxql, ">= 0.0.0"}; - em
config/dev.exs(e emconfig/test.exseconfig/runtime.exs), ajustar usuário, senha e banco, por exemplousername: "root"epassword: ""no MySQL; - em
lib/hello/repo.ex, trocaradapter: Ecto.Adapters.PostgresporEcto.Adapters.MyXQL.
Depois, mix deps.get, mix ecto.create e mix ecto.migrate. O exemplo de mix.exs do guia ainda mostra {:phoenix, "~> 1.4.0"}, de uma versão antiga; o que importa nele são as linhas do Ecto.
O adaptador é a única peça do Repo que conhece o banco:
Hello.Repo.__adapter__()
Schemas, changesets e a maior parte das consultas continuam iguais depois da troca. As exceções são recursos de um banco específico: o ilike usado neste notebook, por exemplo, existe no PostgreSQL e não no MySQL.
Outras opções (Other options): o Erlang traz armazenamentos chave-valor próprios, o ETS, em memória, e o DETS, em disco. O ETS pode ser usado pelo Ecto com o adaptador etso. Os guias do Phoenix não tratam dessas opções.
Sem roteiro: os produtos do Brechó
Agora o mesmo assunto sem instruções passo a passo. O Brechó, o mini-marketplace da trilha, passa a guardar os produtos no banco. Faça:
- Uma migration que crie a tabela
produtos, comnome(texto),preco_centavos(inteiro),vendido(booleano, padrãofalse) e os timestamps. Registre-a com a versão20250320000000e rode omix ecto.migrate. Preço em centavos, inteiro, evita os erros de arredondamento dos números com vírgula. - O schema
Hello.Produto, com umchangeset/2que exijanomeepreco_centavose só aceite preço maior que zero (validate_number/3). Um produto novo deve tervendidoigual afalse. GET /produtos: uma página que lista os produtos não vendidos, do mais barato ao mais caro, buscando no banco.POST /produtos: recebe os campos emparams["produto"], como um formulário do Phoenix envia (produto[nome],produto[preco_centavos]), insere pelo changeset e responde em JSON: status 201 com oidquando dá certo, ou 422 (Unprocessable Entity) com os erros detraverse_errors/2quando não dá.
Use as células abaixo e edite a do router.
# priv/repo/migrations/20250320000000_create_produtos.exs
# lib/hello/produto.ex
# lib/hello_web/controllers/produto_controller.ex e produto_html.ex
Dica: o POST pode ser testado assim, sem o navegador:
Trilha.mostrar(:post, "/produtos", %{"produto" => %{"nome" => "Jaqueta jeans", "preco_centavos" => "4500"}})
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Repita o guia em um projeto gerado por mix phx.new hello. A correspondência entre as células e os arquivos e comandos é esta:
| Neste notebook | No projeto |
|---|---|
Célula config/dev.exs |
config/dev.exs, bloco config :hello, Hello.Repo |
Célula do Hello.Repo |
lib/hello/repo.ex |
| Célula que inicia o Repo e o endpoint | lib/hello/application.ex e mix phx.server |
Células das migrations + Trilha.migracao/2 |
Arquivos em priv/repo/migrations/, com a versão no nome |
Ecto.Adapters.Postgres.storage_up/1 |
mix ecto.create |
Ecto.Adapters.Postgres.storage_down/1 |
mix ecto.drop |
Ecto.Migrator.run(..., :up, all: true) |
mix ecto.migrate |
Ecto.Migrator.run(..., :down, step: 1) |
mix ecto.rollback |
Trilha.relacoes() e Trilha.descrever("users") |
psql -U postgres hello_dev, depois \d e \d users |
Células com Hello.Repo e Hello.User |
iex -S mix, com alias Hello.{Repo, User} |
| Avaliar de novo a célula do schema | recompile() no IEx |
Trilha.recomecar() |
mix ecto.reset (apaga, cria, migra e roda os seeds) |
Roteiro:
- Rode
mix ecto.createno projeto. Se der erro, compare a mensagem com as desta lição. - Rode
mix phx.gen.schema User users name:string email:string bio:string number_of_pets:integer, leia os dois arquivos gerados e rodemix ecto.migrate. Abrapsql -U postgres hello_deve use\de\d users. - No
iex -S mix, refaça a seção de changesets, usandorecompile()depois de cada mudança no schema. - Insira usuários, consulte-os e observe o log
[debug] QUERY OKno IEx. - Gere a migration dos comentários com
mix ecto.gen.migration add_comments_table, rodemix ecto.migrate, depoismix ecto.rollback, e confiraschema_migrationsnopsql. - Tente
mix ecto.dropcom omix phx.serverrodando em outro terminal e leia o erro.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que são o Repo, o schema, o changeset e a migration, e o que acontece entre Repo.insert(changeset) e a linha aparecer na tabela. Depois compare com os pontos abaixo.
- O Repo é a interface com o banco: consultas, escritas, pool de conexões. Ele lê a configuração da aplicação (
otp_app) e fala com o banco pelo adaptador. - A migration altera a estrutura do banco. Cada uma tem uma versão, e a tabela
schema_migrationsregistra as que já rodaram.change/0sabe ser desfeita;ecto.migrateavança eecto.rollbackvolta. - O schema é uma struct que descreve como os campos correspondem às colunas de uma tabela.
create tablecria oid;timestampscriainserted_ateupdated_at. - O changeset filtra (
cast/3só aceita os campos da lista), converte tipos ("3"vira3;"muitos"vira o errois invalid) e valida. Os erros ficam no changeset, não viram exceção. Repo.insertcom um changeset inválido devolve{:error, changeset}sem tocar no banco. Com uma struct, insere sem validar.- Restrições do banco, como um índice único, são a última barreira.
unique_constraint/2converte a recusa do banco em erro do changeset. insert_all,update_alledelete_allagem em lote, com um só SQL, sem changesets nem timestamps automáticos.- Trocar de banco é trocar a dependência, o adaptador e a configuração. Schemas e changesets não mudam.
Próximo guia da trilha: Contexts, o primeiro dos guias de modelagem de dados, que organiza o acesso ao Repo em módulos com uma API própria.