Trilha Phoenix · Contextos
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 como
o psql mostraria, registra migrations, faz requisições ao endpoint como um
navegador faria, 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` 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, e esquece os visitantes."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
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 "Mostra as colunas, os índices e as chaves estrangeiras 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)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
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(%Decimal{} = valor), do: "`" <> Decimal.to_string(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, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @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
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
end)
|> String.split("\n")
|> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- rastreio: quem chama quem -------------------------------------------------
# As funções do Repo que aparecem no rastreio (as internas ficam de fora).
@api_repo ~w(all all_by one one! get get! get_by get_by! insert insert! update update! delete delete!
insert_all update_all delete_all preload transaction transact aggregate exists? reload reload!
insert_or_update stream)a
@doc """
Executa `fun` e mostra, em árvore, as actions dos controllers, as funções dos
módulos `Hello.*` (os contextos) e as chamadas ao `Hello.Repo`: quem chamou quem.
"""
def rastrear(fun) do
{_resultado, chamadas} = com_rastreio(fun)
linhas =
for {profundidade, modulo, funcao, aridade, _quem} <- chamadas do
String.duplicate(" ", profundidade) <> "#{inspect(modulo)}.#{funcao}/#{aridade}"
end
Kino.Markdown.new("**Quem chamou quem**\n\n" <> cerca(Enum.join(linhas, "\n"), ""))
end
# Liga o rastreio de chamadas do próprio processo (as requisições do
# ConnTest rodam nele), executa `fun` e devolve {resultado, chamadas}.
# As mensagens de rastreio vão para um processo coletor: um processo não
# pode ser o próprio rastreador.
defp com_rastreio(fun) do
modulos = modulos_rastreados()
com_retorno = [{:_, [], [{:return_trace}]}]
coletor = spawn(fn -> coletar_rastros([]) end)
Enum.each(modulos, &:erlang.trace_pattern({&1, :_, :_}, com_retorno, [:global]))
:erlang.trace_pattern({@repo, :_, :_}, true, [:global])
:erlang.trace(self(), true, [:call, {:tracer, coletor}])
resultado =
try do
fun.()
after
:erlang.trace(self(), false, [:call])
Enum.each(modulos, &:erlang.trace_pattern({&1, :_, :_}, false, [:global]))
:erlang.trace_pattern({@repo, :_, :_}, false, [:global])
end
ref = :erlang.trace_delivered(self())
receive do
{:trace_delivered, _, ^ref} -> :ok
end
send(coletor, {:fim, self()})
receive do
{:rastros, ^coletor, mensagens} -> {resultado, montar_chamadas(mensagens)}
end
end
defp modulos_rastreados do
for {modulo, _} <- :code.all_loaded(),
nome = Atom.to_string(modulo),
(String.starts_with?(nome, "Elixir.Hello.") and not String.starts_with?(nome, "Elixir.Hello.Repo")) or
(String.starts_with?(nome, "Elixir.HelloWeb.") and String.ends_with?(nome, "Controller")),
not function_exported?(modulo, :__schema__, 1),
do: modulo
end
defp coletar_rastros(acc) do
receive do
{:fim, quem} -> send(quem, {:rastros, self(), Enum.reverse(acc)})
mensagem -> coletar_rastros([mensagem | acc])
end
end
# Cada chamada vira {profundidade, módulo, função, aridade, quem_chamou}.
defp montar_chamadas(mensagens) do
{_pilha, saida} =
Enum.reduce(mensagens, {[], []}, fn
{:trace, _, :call, {@repo, funcao, args}}, {pilha, saida} ->
if funcao in @api_repo,
do: {pilha, [{visiveis(pilha), @repo, funcao, length(args), chamador(pilha)} | saida]},
else: {pilha, saida}
{:trace, _, :call, {modulo, funcao, args}}, {pilha, saida} ->
visivel = funcao not in [:call, :init, :action] and not String.starts_with?(Atom.to_string(funcao), "__")
saida = if visivel, do: [{visiveis(pilha), modulo, funcao, length(args), chamador(pilha)} | saida], else: saida
{[{modulo, visivel} | pilha], saida}
{:trace, _, :return_from, _, _}, {pilha, saida} ->
{Enum.drop(pilha, 1), saida}
end)
Enum.reverse(saida)
end
defp visiveis(pilha), do: Enum.count(pilha, &elem(&1, 1))
defp chamador(pilha), do: Enum.find_value(pilha, fn {modulo, visivel} -> visivel && modulo end)
# As chamadas ao Repo que `modulo` fez diretamente, sem passar por um contexto.
defp chamadas_diretas_ao_repo(modulo, fun) do
{_resultado, chamadas} = com_rastreio(fun)
for {_, @repo, funcao, aridade, ^modulo} <- chamadas, uniq: true, do: "`Hello.Repo.#{funcao}/#{aridade}`"
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
%{
contexto_plural: %{
texto:
"o comando `mix phx.gen.live Post posts title body:text` não diz o nome de nenhum contexto. Onde o gerador coloca o schema e as funções de acesso aos posts?",
opcoes: [
a: "Tudo em `MyApp.Post`, que é ao mesmo tempo schema e API.",
b: "O schema em `MyApp.Posts.Post` e as funções no contexto `MyApp.Posts`, o plural do recurso.",
c: "O schema em `MyApp.Post` e as funções na LiveView `MyAppWeb.PostLive`.",
d: "Em lugar nenhum: sem o nome do contexto, o gerador para com um erro."
],
correta: :b,
explicacao:
"Quando o nome do contexto fica de fora, o Phoenix usa o plural do recurso. O resultado é o schema `MyApp.Posts.Post`, em `lib/my_app/posts/post.ex`, e o contexto `MyApp.Posts`, que concentra todo o acesso aos dados dos posts. A LiveView chama o contexto; ela não fala com o Repo."
},
comments: %{
texto:
"em seguida o guia roda `mix phx.gen.live Posts Comment comments post_id:references:posts body:text`. O que o primeiro argumento, `Posts`, decide?",
opcoes: [
a: "O nome da tabela dos comentários.",
b: "Que os comentários ficam no contexto `MyApp.Posts`, ao lado dos posts: o schema vira `MyApp.Posts.Comment` e as funções novas entram em `MyApp.Posts`.",
c: "Que cada comentário pertence a um post: é ele que cria a chave estrangeira.",
d: "O nome da LiveView gerada."
],
correta: :b,
explicacao:
"O primeiro argumento é o contexto. Como `MyApp.Posts` já existe, o gerador acrescenta funções a ele em vez de criar outro módulo. A chave estrangeira vem de `post_id:references:posts`, e a tabela se chama `comments`, o terceiro argumento. Agrupar recursos relacionados no mesmo contexto deixa visível como as partes do sistema se relacionam."
},
carrinho: %{
texto: "o carrinho de compras guarda produtos do catálogo. Onde o guia decide colocar o carrinho?",
opcoes: [
a: "No `Catalog`, porque um carrinho é feito de produtos.",
b: "Num contexto próprio, `ShoppingCart`: exibir produtos e controlar o que cada pessoa pôs no carrinho são responsabilidades diferentes.",
c: "No `ProductController`, porque é ele que mostra os produtos.",
d: "No schema `Product`, num campo `in_cart`."
],
correta: :b,
explicacao:
"Os dados se relacionam, mas as regras não. O catálogo cuida da exibição dos produtos; o carrinho, de quem pôs o quê, em que quantidade e a que preço. O guia separa os dois desde o começo, e o contexto `ShoppingCart` aparece no notebook 10."
},
rotas_resources: %{
texto: "o gerador pede uma linha no router: `resources \"/products\", ProductController`. Quantas rotas essa linha cria?",
opcoes: [
a: "Uma, para `/products`.",
b: "Quatro: listar, criar, atualizar e apagar.",
c: "Sete, uma por action.",
d: "Oito: as sete actions, com `update` atendendo a `PATCH` e a `PUT`."
],
correta: :d,
explicacao:
"`resources` cria as rotas das sete actions REST: `index`, `edit`, `new`, `show`, `create`, `update` e `delete`. A action `update` aparece duas vezes, uma com `PATCH` e outra com `PUT`, por isso são oito linhas. O notebook 03 mostrou essas rotas na seção sobre resources."
},
salvar_vazio: %{
texto: "você envia o formulário de produto com todos os campos em branco. Que resposta o `ProductController.create/2` gerado devolve?",
opcoes: [
a: "Um redirect para `/products/new`, com uma flash de erro.",
b: "Status 200, com o formulário de novo e uma mensagem de erro embaixo de cada campo.",
c: "Status 422, com os erros em JSON.",
d: "Status 500, porque o banco recusa a linha."
],
correta: :b,
explicacao:
"`Catalog.create_product/1` devolve `{:error, changeset}`, e o controller renderiza o template `new` com esse changeset. O formulário lê os erros do changeset e os mostra ao lado de cada campo. Não há redirect, que perderia o que a pessoa digitou. O banco nem é consultado: com o changeset inválido, o `Repo.insert/1` desiste antes do `INSERT`."
},
troca_de_armazenamento: %{
texto:
"um dia a lista de produtos passa a vir de um motor de busca de texto, e não mais de uma consulta SQL. Quais módulos precisam mudar?",
opcoes: [
a: "O `ProductController` e o `Hello.Catalog`.",
b: "Só o `Hello.Catalog` (e o que ele usa por dentro): o controller continua chamando `Catalog.list_products/0`.",
c: "Só os templates.",
d: "Todos, porque o Repo aparece em todo o código web."
],
correta: :b,
explicacao:
"O controller pede a lista de produtos pelo nome da intenção, `list_products/0`, e não sabe de onde ela vem. Trocar a origem dos dados muda o corpo dessa função, e só ele. É o que o guia chama de desacoplar a lógica de negócio e o armazenamento da camada web."
},
doc_false: %{
texto: "`Product.changeset/2` tem `@doc false` logo acima. O que isso quer dizer?",
opcoes: [
a: "Que a função é privada: só o próprio schema pode chamá-la.",
b: "Que a função é pública, mas não faz parte da API do contexto: quem está fora do `Catalog` monta changesets pelo `Catalog`, com `change_product/2`.",
c: "Que o Ecto não valida os dados que passam por ela.",
d: "Que a função só existe no ambiente de desenvolvimento."
],
correta: :b,
explicacao:
"`@doc false` esconde a função da documentação, mas ela continua pública: o `Catalog` precisa chamá-la, e ele é outro módulo. A convenção diz ao resto do código que aquela função é um detalhe interno. Controllers e templates usam `Catalog.change_product/2`, `create_product/1` e `update_product/2`."
},
corrida: %{
texto:
"dois visitantes abrem ao mesmo tempo a página de um produto com 13 visualizações. Cada visita lê o produto e grava `Catalog.update_product(product, %{views: product.views + 1})`. Com que valor `views` pode terminar?",
opcoes: [
a: "Sempre 15.",
b: "15 ou 14, conforme a ordem em que as leituras e as gravações acontecem.",
c: "Sempre 14.",
d: "Em nenhum: o banco recusa a segunda gravação."
],
correta: :b,
explicacao:
"Se as duas visitas leem 13 antes de qualquer uma gravar, as duas gravam 14, e uma visualização se perde. Se uma termina antes de a outra ler, o resultado é 15. O banco não recusa nada: cada `UPDATE` grava um número fixo, calculado com um valor que já pode estar desatualizado."
},
update_all: %{
texto: "na função do guia, `Repo.update_all/2` devolve `{1, [%Product{views: 14}]}`. O que é o `1`?",
opcoes: [
a: "O `id` do produto.",
b: "O número de linhas que o `UPDATE` alterou.",
c: "O valor que foi somado a `views`.",
d: "O número de consultas que o Ecto fez."
],
correta: :b,
explicacao:
"`update_all/2` devolve o número de linhas alteradas e a lista do que o `select` pediu: aqui, só o campo `views` de cada linha, numa struct `Product` parcial. O padrão `{1, [...]}` também serve de verificação: para um produto que não existe, o resultado seria `{0, []}`, e o casamento de padrões falharia com `MatchError`."
}
}
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
%{
migration: ~S'''
defmodule Hello.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products) do
add :title, :string
add :description, :string
add :price, :decimal, precision: 15, scale: 6, null: false
add :views, :integer, default: 0, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
''',
importador: ~S'''
defmodule Hello.Importador do
@moduledoc "Importa produtos de um texto com uma linha por produto: título;descrição;preço."
alias Hello.Catalog
def importar(texto) do
texto
|> String.split("\n", trim: true)
|> Enum.map(fn linha ->
[title, description, price] = String.split(linha, ";")
Catalog.create_product(%{
"title" => title,
"description" => description,
"price" => price,
"views" => "0"
})
end)
end
end
''',
inc_page_views: ~S'''
# Dentro de defmodule Hello.Catalog, junto das outras funções:
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
''',
show: ~S'''
# Em HelloWeb.ProductController:
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
''',
brecho: ~S'''
# lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
defmodule Hello.Vitrine.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
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
# No router, no scope "/" com :browser, ao lado das rotas de /produtos:
post "/produtos/:id/vender", ProdutoController, :vender
# lib/hello_web/controllers/produto_controller.ex e produto_html.ex
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
alias Hello.Vitrine
def index(conn, _params) do
render(conn, :index, produtos: Vitrine.listar_disponiveis())
end
def create(conn, %{"produto" => attrs}) do
case Vitrine.cadastrar_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
def vender(conn, %{"id" => id}) do
case Vitrine.vender(Vitrine.buscar_produto!(id)) do
{:ok, produto} ->
conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")
{:error, :ja_vendido} ->
conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
end
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>Vitrine do Brechó</.header>
<.table id="produtos" rows={@produtos}>
<:col :let={produto} label="Peça">{produto.nome}</:col>
<:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
<:action :let={produto}>
<.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
</:action>
</.table>
</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
%{
migration: [
{"A tabela `products` existe, com `title`, `description`, `price`, `views` e os timestamps.",
fn -> colunas("products", ~w(title description price views inserted_at updated_at)) end},
{"`price` tem precisão 15, escala 6 e não aceita `NULL`.",
fn ->
case coluna_info("price") do
%{"numeric_precision" => 15, "numeric_scale" => 6, "is_nullable" => "NO"} ->
:ok
info ->
{:erro,
"A coluna ficou com precisão `#{inspect(info["numeric_precision"])}`, escala `#{inspect(info["numeric_scale"])}` " <>
"e `is_nullable` igual a `#{info["is_nullable"]}`. " <> dica_refazer_migration()}
end
end},
{"`views` tem o valor padrão 0 e não aceita `NULL`.",
fn ->
case coluna_info("views") do
%{"column_default" => "0", "is_nullable" => "NO"} ->
:ok
info ->
{:erro,
"A coluna ficou com padrão `#{inspect(info["column_default"])}` e `is_nullable` igual a `#{info["is_nullable"]}`. " <>
dica_refazer_migration()}
end
end}
],
importador: [
{"`Hello.Importador` define `importar/1`.", fn -> exporta(Hello.Importador, importar: 1) end},
{"Cada linha vira um resultado: `{:ok, produto}` para as válidas e `{:error, changeset}` para a linha sem título.",
fn ->
limpando_products(fn ->
texto =
"Conferência: Programming Phoenix;Productive |> Reliable |> Fast;42.50\n" <>
";Uma linha sem título;10\n" <>
"Conferência: Elixir in Action;Third edition;55"
case apply(Hello.Importador, :importar, [texto]) do
[{:ok, %{title: "Conferência: Programming Phoenix"}}, {:error, %Ecto.Changeset{}}, {:ok, %{title: "Conferência: Elixir in Action"}}] ->
if coluna("SELECT count(*) FROM products WHERE title LIKE 'Conferência:%' AND views = 0") == [2],
do: :ok,
else: {:erro, "Os resultados vieram certos, mas a tabela não tem os dois produtos com `views` igual a 0."}
[{:error, %Ecto.Changeset{errors: erros}} | _] ->
if Keyword.has_key?(erros, :views),
do: {:erro, "A primeira linha falhou com erro em `views`. O banco tem padrão 0, mas o changeset gerado exige o campo: passe `\"views\" => \"0\"`."},
else: {:erro, "A primeira linha, que é válida, falhou com `#{inspect(erros)}`."}
outro ->
{:erro, "Esperada uma lista com um resultado por linha, na ordem das linhas. Veio `#{inspect(outro, limit: 6)}`."}
end
end)
end},
{"O importador só fala com o `Hello.Catalog`; quem chama o Repo é o contexto.",
fn ->
limpando_products(fn ->
case chamadas_diretas_ao_repo(Hello.Importador, fn -> apply(Hello.Importador, :importar, ["Conferência: Rastreio;Uma linha;1"]) end) do
[] -> :ok
diretas -> {:erro, "`Hello.Importador` chamou #{Enum.join(diretas, ", ")} diretamente. Use `Hello.Catalog.create_product/1`."}
end
end)
end}
],
inc_page_views: [
{"`Hello.Catalog` define `inc_page_views/1`.", fn -> exporta(Hello.Catalog, inc_page_views: 1) end},
{"A função devolve o produto com `views` um a mais, e o banco guarda o mesmo valor.",
fn ->
limpando_products(fn ->
produto = produto_de_teste(5)
novo = apply(Hello.Catalog, :inc_page_views, [produto])
cond do
not is_struct(novo, Hello.Catalog.Product) ->
{:erro, "A função devolveu `#{inspect(novo, limit: 5)}`; o esperado é a struct `%Product{}` com `views` atualizado."}
novo.views != 6 ->
{:erro, "O produto devolvido tem `views` igual a `#{inspect(novo.views)}`; o esperado é 6. Use `put_in(product.views, views)` com o valor que o `select` trouxe."}
views_no_banco(produto) != 6 ->
{:erro, "O banco ficou com `views` igual a `#{views_no_banco(produto)}`; o esperado é 6."}
true ->
:ok
end
end)
end},
{"100 incrementos simultâneos somam exatamente 100.",
fn ->
limpando_products(fn ->
produto = produto_de_teste(0)
ao_mesmo_tempo(100, fn -> apply(Hello.Catalog, :inc_page_views, [produto]) end)
case views_no_banco(produto) do
100 -> :ok
n -> {:erro, "Ficou `#{n}`. A função lê o valor e depois grava? O incremento precisa acontecer dentro do `UPDATE`, com `Repo.update_all(consulta, inc: [views: 1])`."}
end
end)
end}
],
show: [
{"Cada `GET /products/:id` soma uma visualização no banco.",
fn ->
limpando_products(fn ->
produto = produto_de_teste(0)
requisicao(:get, "/products/#{produto.id}")
requisicao(:get, "/products/#{produto.id}")
case views_no_banco(produto) do
2 -> :ok
0 -> {:erro, "Duas visitas e `views` continua 0. A action `show` precisa passar o produto por `Catalog.inc_page_views/1`."}
n -> {:erro, "Depois de duas visitas, `views` ficou `#{n}`; o esperado é 2."}
end
end)
end},
{"A página mostra o valor já incrementado.",
fn ->
limpando_products(fn ->
produto = produto_de_teste(0)
case requisicao(:get, "/products/#{produto.id}") do
{:ok, %{status: 200, resp_body: corpo}} ->
if texto(to_string(corpo)) =~ "Views\n1",
do: :ok,
else: {:erro, "O banco foi atualizado, mas a página não mostra `Views 1`. Renderize o produto que `inc_page_views/1` devolve, não o que veio antes dele."}
{:ok, conn} ->
{:erro, "A página respondeu com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end}
],
brecho: [
{"`Hello.Vitrine.Produto` é um schema da tabela `produtos`.",
fn ->
cond do
not Code.ensure_loaded?(Hello.Vitrine.Produto) ->
{:erro, "Defina o módulo `Hello.Vitrine.Produto`: é o `Hello.Produto` do notebook 07, com o nome novo."}
not function_exported?(Hello.Vitrine.Produto, :__schema__, 1) ->
{:erro, "`Hello.Vitrine.Produto` existe, mas não é um schema: falta `use Ecto.Schema` e o bloco `schema \"produtos\"`."}
apply(Hello.Vitrine.Produto, :__schema__, [:source]) != "produtos" ->
{:erro, "O schema aponta para a tabela `#{apply(Hello.Vitrine.Produto, :__schema__, [:source])}`; o certo é `produtos`."}
true ->
:ok
end
end},
{"`Hello.Vitrine` define `listar_disponiveis/0`, `buscar_produto!/1`, `cadastrar_produto/1` e `vender/1`.",
fn -> exporta(Hello.Vitrine, listar_disponiveis: 0, buscar_produto!: 1, cadastrar_produto: 1, vender: 1) end},
{"`cadastrar_produto/1` grava um produto válido e devolve `{:error, changeset}` para preço zero.",
fn ->
limpando_produtos(fn ->
bom = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Camisa", "preco_centavos" => "3000"}])
ruim = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Grátis", "preco_centavos" => "0"}])
case {bom, ruim} do
{{:ok, %{__struct__: Hello.Vitrine.Produto}}, {:error, %Ecto.Changeset{}}} -> :ok
{{:ok, _}, outro} -> {:erro, "Com preço zero, veio `#{inspect(outro, limit: 5)}`. O changeset precisa de `validate_number/3` com `greater_than: 0`."}
{outro, _} -> {:erro, "Com dados válidos, veio `#{inspect(outro, limit: 5)}`; o esperado é `{:ok, %Hello.Vitrine.Produto{}}`."}
end
end)
end},
{"Um formulário não consegue marcar uma peça como vendida: `cadastrar_produto/1` ignora `vendido`.",
fn ->
limpando_produtos(fn ->
case apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Esperta", "preco_centavos" => "100", "vendido" => "true"}]) do
{:ok, %{vendido: false}} -> :ok
{:ok, %{vendido: true}} -> {:erro, "A peça foi cadastrada já vendida. Tire `:vendido` da lista do `cast/3` em `Hello.Vitrine.Produto.changeset/2`."}
outro -> {:erro, "Veio `#{inspect(outro, limit: 5)}`; o esperado é `{:ok, produto}` com `vendido: false`."}
end
end)
end},
{"`listar_disponiveis/0` traz só os não vendidos, do mais barato ao mais caro.",
fn ->
limpando_produtos(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
nomes =
apply(Hello.Vitrine, :listar_disponiveis, [])
|> Enum.map(& &1.nome)
|> Enum.filter(&String.starts_with?(&1, "Conferência"))
case nomes do
["Conferência Barato", "Conferência Caro"] -> :ok
outro -> {:erro, "Entre os produtos de teste, veio `#{inspect(outro)}`; o esperado é `[\"Conferência Barato\", \"Conferência Caro\"]`."}
end
end)
end},
{"`vender/1` vende uma vez e recusa a segunda venda com `{:error, :ja_vendido}`.",
fn ->
limpando_produtos(fn ->
peca = peca_de_teste("Conferência Bota")
case {apply(Hello.Vitrine, :vender, [peca]), apply(Hello.Vitrine, :vender, [peca])} do
{{:ok, %{vendido: true}}, {:error, :ja_vendido}} -> :ok
{{:ok, %{vendido: false}}, _} -> {:erro, "A primeira venda devolveu o produto com `vendido: false`. Devolva o produto já atualizado."}
{primeira, segunda} -> {:erro, "As duas vendas devolveram `#{inspect(primeira, limit: 4)}` e `#{inspect(segunda, limit: 4)}`."}
end
end)
end},
{"20 vendas simultâneas da mesma peça: exatamente uma dá certo.",
fn ->
limpando_produtos(fn ->
peca = peca_de_teste("Conferência Jaqueta")
resultados = ao_mesmo_tempo(20, fn -> apply(Hello.Vitrine, :vender, [peca]) end)
case Enum.count(resultados, &match?({:ok, {:ok, _}}, &1)) do
1 -> :ok
n -> {:erro, "#{n} das 20 vendas deram certo. A verificação de `vendido` e a mudança precisam estar no mesmo `UPDATE` (um `update_all` com `where: not p.vendido`)."}
end
end)
end},
{"`POST /produtos/:id/vender` volta para `/produtos` com uma flash de sucesso e, na segunda vez, com uma flash de erro.",
fn ->
limpando_produtos(fn ->
peca = peca_de_teste("Conferência Vestido")
esquecer_visitantes()
with {:ok, primeira} <- visitar("conferência", :post, "/produtos/#{peca.id}/vender"),
{:ok, segunda} <- visitar("conferência", :post, "/produtos/#{peca.id}/vender") do
cond do
primeira.request_path != "/produtos" or segunda.request_path != "/produtos" ->
{:erro, "Depois da venda, a última página foi `#{primeira.request_path}`; o esperado é voltar para `/produtos`."}
Phoenix.Flash.get(primeira.assigns.flash, :info) == nil ->
{:erro, "A primeira venda não deixou uma flash `:info`."}
Phoenix.Flash.get(segunda.assigns.flash, :error) == nil ->
{:erro, "A segunda venda da mesma peça não deixou uma flash `:error`."}
true ->
:ok
end
else
{:erro, erro} -> {:erro, explicar(erro)}
end
end)
end},
{"A vitrine em `GET /produtos` mostra as peças à venda, cada uma com um link para vender.",
fn ->
limpando_produtos(fn ->
peca = peca_de_teste("Conferência Saia")
case requisicao(:get, "/produtos") do
{:ok, %{status: 200, resp_body: corpo}} ->
corpo = to_string(corpo)
cond do
not (corpo =~ "Conferência Saia") -> {:erro, "A página não mostra a peça à venda."}
not (corpo =~ ~s(href="/produtos/#{peca.id}/vender")) -> {:erro, "Falta, para cada peça, um `<.link href={~p\"/produtos/\#{produto}/vender\"} method=\"post\">`."}
not (corpo =~ ~s(data-method="post")) -> {:erro, "O link de venda precisa de `method=\"post\"`."}
true -> :ok
end
{:ok, conn} ->
{:erro, "A página respondeu com status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"O `ProdutoController` não chama o Repo: quem fala com o banco é a `Hello.Vitrine`.",
fn ->
limpando_produtos(fn ->
peca = peca_de_teste("Conferência Boné")
diretas =
chamadas_diretas_ao_repo(HelloWeb.ProdutoController, fn ->
requisicao(:get, "/produtos")
requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Cinto", "preco_centavos" => "900"}})
requisicao(:post, "/produtos/#{peca.id}/vender")
end)
if diretas == [],
do: :ok,
else: {:erro, "O controller chamou #{Enum.join(diretas, ", ")} diretamente. Troque cada chamada por uma função da `Hello.Vitrine`."}
end)
end}
]
}
end
# --- ajudantes deste notebook ----------------------------------------------------
defp coluna_info(nome) do
resultado =
sql(
"""
SELECT numeric_precision, numeric_scale, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'products' AND column_name = $1
""",
[nome]
)
case resultado.rows do
[linha] -> Map.new(Enum.zip(resultado.columns, linha))
[] -> %{}
end
end
defp dica_refazer_migration do
"Se a tabela foi criada antes da mudança, desfaça a migration com " <>
"`Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, step: 1)`, " <>
"avalie a célula da migration já alterada e rode de novo a célula do `mix ecto.migrate`."
end
# Um produto do catálogo criado só para a conferência.
defp produto_de_teste(views) do
{:ok, produto} =
apply(Hello.Catalog, :create_product, [
%{"title" => "Conferência: contador", "description" => "Produto de teste", "price" => "1", "views" => to_string(views)}
])
produto
end
defp views_no_banco(produto) do
[views] = coluna("SELECT views FROM products WHERE id = $1", [produto.id])
views
end
# Uma peça do Brechó criada só para a conferência.
defp peca_de_teste(nome) do
{:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => nome, "preco_centavos" => "2000"}])
peca
end
# Executa a conferência e apaga, no fim, o que ela criou.
defp limpando_products(teste) do
teste.()
after
if coluna("SELECT to_regclass('public.products')::text") != [nil] do
sql("DELETE FROM products WHERE title LIKE 'Conferência%'")
end
end
defp limpando_produtos(teste) do
teste.()
after
if coluna("SELECT to_regclass('public.produtos')::text") != [nil] do
sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
end
# Executa `fun` `vezes` vezes ao mesmo tempo, cada uma num processo sem link,
# e devolve {:ok, resultado} ou {:excecao, erro} de cada uma.
defp ao_mesmo_tempo(vezes, fun) do
pai = self()
silenciando(fn ->
processos =
for _ <- 1..vezes do
spawn_monitor(fn ->
resultado =
try do
{:ok, fun.()}
rescue
erro -> {:excecao, erro}
end
send(pai, {:resultado, self(), resultado})
end)
end
for {pid, ref} <- processos do
receive do
{:resultado, ^pid, resultado} ->
receive do
{:DOWN, ^ref, _, _, _} -> resultado
end
{:DOWN, ^ref, _, _, motivo} ->
{:excecao, motivo}
end
end
end)
end
# Executa `fun` sem os logs de cada consulta (úteis, mas centenas numa corrida).
defp silenciando(fun) do
nivel = Logger.level()
Logger.configure(level: :info)
try do
fun.()
after
Logger.configure(level: nivel)
end
end
# --- ajudantes da conferência --------------------------------------------------
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 a célula do `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 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 a operação 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 recebidos."
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook abre o módulo 6 da trilha, sobre contextos. Ele acompanha os dois primeiros capítulos dos guias de modelagem de dados do Phoenix 1.8, Intro to Contexts e Your First Context, na mesma ordem e com o link de cada seção. Os notebooks 09, 10 e 11 seguem com os outros capítulos. O próprio guia avisa que esses capítulos devem ser lidos em ordem: cada um continua a aplicação do anterior.
Até aqui, a trilha tratou das peças isoladas: requisição, router, controller, componentes, Ecto. Os contextos são o lugar onde elas se encontram. É neles que fica o acesso aos dados e as regras do negócio, e é por eles que a parte web conversa com o resto da aplicação.
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. Há duas ferramentas novas no módulo Trilha:
Trilha.navegar(quem, metodo, caminho, parametros)faz uma requisição como um navegador: guarda os cookies de cada visitante (quemé um nome qualquer, como"ana"), segue os redirects e mostra a flash e o texto da página;Trilha.rastrear(fn -> ... end)executa o código e mostra quem chamou quem: a action do controller, a função do contexto e a chamada ao Repo.
O guia usa um gerador, mix phx.gen.html, que um notebook não tem. Aqui, os arquivos que ele cria aparecem como células, com o código que o Phoenix 1.8.16 gerou de verdade. A seção "No seu projeto" volta ao terminal.
Este notebook usa o PostgreSQL configurado no notebook 07. A aplicação fica em http://localhost:4010: além das células, você pode abrir as páginas no navegador e clicar. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
As células desta seção são a aplicação Hello no ponto em que o guia começa: um projeto novo, com Ecto, ainda sem nenhum recurso. São as mesmas peças dos notebooks anteriores, com três novidades que os arquivos gerados pelo Phoenix usam: os CoreComponents completos, os estilos do Tailwind e do daisyUI no root layout e o JavaScript do phoenix_html. O router vem antes dos controllers, por causa do ~p; o aviso HelloWeb.PageController.init/1 is undefined que ele mostra é esperado.
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. 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
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.
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.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
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
lib/hello_web/router.ex
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
lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.
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.Static,
at: "/assets",
from: {:phoenix_html, "priv/static"},
only: ~w(phoenix_html.js)
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/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
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
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Introdução aos contextos
Na documentação: Intro to Contexts
Os guias do Phoenix se dividem em seções. Na de conceitos centrais, vimos o ciclo de vida da requisição, ligamos actions de controller pelo router e aprendemos como o Ecto valida e grava dados. Agora é hora de juntar tudo: escrever funcionalidades web que conversam com a aplicação Elixir maior.
Um projeto Phoenix é, antes de tudo, uma aplicação Elixir. O papel do Phoenix é dar a essa aplicação uma interface web. A aplicação é feita de módulos e funções, e alguns módulos ganham nomes por causa da responsabilidade que têm: controllers, routers, live views.
A parte mais importante de uma aplicação web, porém, costuma ser o lugar onde ficam o acesso aos dados e a validação deles. O Phoenix chama esses módulos de contextos. Em geral eles falam com um banco de dados, pelo Ecto, ou com APIs externas, por um cliente HTTP como o Req. Dar um nome a esses módulos ajuda a reconhecer o padrão e a conversar sobre ele. No fim das contas, contextos são só módulos, como os controllers e as views.
Quem já usou mix phx.gen.html, mix phx.gen.json ou mix phx.gen.live já usou contextos. O guia dá um exemplo:
$ mix phx.gen.live Post posts title body:text
Trilha.pergunta(:contexto_plural)
O comando cria vários arquivos. Entre eles, o schema MyApp.Posts.Post, em lib/my_app/posts/post.ex, que descreve como o recurso é guardado no banco, e um contexto chamado MyApp.Posts, que concentra todo o acesso aos dados desse schema. O módulo MyApp.Posts reúne tudo o que diz respeito a posts, em vez de espalhar essa lógica por controllers e LiveViews.
Os contextos também servem para agrupar recursos. Para acrescentar comentários aos posts, dá para colocar os schemas lado a lado, já que comentários pertencem a posts:
$ mix phx.gen.live Posts Comment comments post_id:references:posts body:text
Trilha.pergunta(:comments)
O primeiro argumento é o módulo do contexto: ele diz ao Phoenix que os comentários moram junto com os posts. O atributo post_id:references:posts cria uma chave estrangeira para a tabela posts. Conforme a aplicação cresce, os contextos agrupam schemas relacionados, em vez de deixar dezenas de schemas soltos sem nenhuma pista de como se relacionam.
Os contextos também dão nome, de propósito, a partes da aplicação. O mix phx.gen.auth, por exemplo, exige o nome do contexto:
$ mix phx.gen.auth Accounts User users
ou outro nome que você prefira:
$ mix phx.gen.auth Identity Client clients
O contexto gerado, Accounts (ou Identity), concentra tudo sobre gerenciar usuários (ou clientes) e seus tokens. Daria para chamá-lo de Users, mas gestão de contas é uma fronteira bem conhecida na maioria das aplicações, e um nome explícito deixa o propósito claro. E, de novo, são só módulos.
A meta destes capítulos é um sistema de e-commerce: mostrar produtos, deixar cada pessoa pôr produtos no carrinho e fechar pedidos. O guia faz isso projetando e nomeando os contextos com intenção. Na trilha, são quatro notebooks: este, com o catálogo de produtos; o 09, com categorias dentro do catálogo; o 10, com o carrinho, um contexto que depende de outro; e o 11, com os pedidos.
A aplicação de e-commerce
Na documentação: Our ecommerce application
O guia começa uma aplicação do zero, chamada hello, com o Phoenix Express:
$ curl https://new.phoenixframework.org/hello | sh
ou, se o comando não funcionar, com mix phx.new hello, depois de seguir o guia de instalação. Neste notebook, a aplicação já existe: são as células da seção "A aplicação Hello dentro do notebook", lá em cima.
O primeiro contexto
Na documentação: Your First Context
Um e-commerce tem acoplamentos espalhados pelo código, por isso vale pensar bem nos módulos. O primeiro objetivo do guia é uma API de catálogo de produtos: criar, atualizar e apagar os produtos do sistema. Mostrar os produtos vem primeiro; o carrinho vem depois. A ideia é que uma base com fronteiras bem isoladas deixe a aplicação crescer naturalmente.
O Phoenix traz os geradores mix phx.gen.html, mix phx.gen.json, mix phx.gen.live e mix phx.gen.context, que já isolam cada funcionalidade em um contexto. Eles são uma boa forma de começar, e o Phoenix aproveita para empurrar você na direção certa.
Ao rodar um gerador, o nome do contexto é opcional: sem ele, o Phoenix usa o plural do recurso, como você viu com Posts. Isso ajuda quando ainda não está claro como as partes do sistema se relacionam. Um e-commerce, porém, é um domínio bem conhecido, então dá para projetar com intenção. Haverá produtos expostos à venda, com descrição e preço. Além de vender, será preciso ter carrinho, fechamento de pedido e assim por diante.
Trilha.pergunta(:carrinho)
Os produtos comprados se relacionam com o carrinho e com o pedido, mas mostrar um produto e gerenciar a exibição dos produtos é bem diferente de controlar o que alguém pôs no carrinho ou como um pedido é fechado. Um contexto Catalog é o lugar natural para os detalhes dos produtos e para a vitrine.
Começando pelos geradores
Na documentação: Starting with generators
Para começar o catálogo, o guia usa mix phx.gen.html, que cria o contexto com as funções de criar, atualizar e apagar produtos pelo Ecto, e também os arquivos web: controller e templates. O comando, na raiz do projeto:
$ mix phx.gen.html Catalog Product products title:string \
description:string price:decimal views:integer
Os argumentos são o contexto (Catalog), o schema (Product), a tabela (products) e os campos com seus tipos. A saída no Phoenix 1.8.16:
* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20250201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex
Add the resource to your browser scope in lib/hello_web/router.ex:
resources "/products", ProductController
Remember to update your repository by running migrations:
$ mix ecto.migrate
O número no nome da migration é a data e a hora em que o gerador rodou; aqui está o do guia. Repare na divisão entre lib/hello e lib/hello_web. Os arquivos web ficam em lib/hello_web/. Em lib/hello/ ficam o módulo Catalog, a API pública de tudo o que diz respeito ao catálogo, e a struct Catalog.Product, um schema do Ecto que converte e valida os dados de um produto, numa pasta com o nome do contexto.
As linhas injecting mostram que o gerador primeiro cria o arquivo do contexto e depois injeta nele as funções do recurso. É o mesmo mecanismo que, no notebook 09, acrescenta categorias a um contexto que já existe. O gerador também cria testes e funções de apoio para testes (as fixtures); o módulo de testes da trilha volta a eles.
Neste notebook, cada arquivo é uma célula:
| Arquivo gerado | Célula |
|---|---|
priv/repo/migrations/20250201185747_create_products.exs |
a migration, logo abaixo |
lib/hello/catalog/product.ex |
o schema Hello.Catalog.Product |
lib/hello/catalog.ex |
o contexto Hello.Catalog |
lib/hello_web/controllers/product_controller.ex |
o HelloWeb.ProductController |
lib/hello_web/controllers/product_html.ex e product_html/* |
o HelloWeb.ProductHTML, uma função por template |
Antes de rodar o mix ecto.migrate, o guia muda a migration gerada.
priv/repo/migrations/20250201185747_create_products.exs: a migration como o gerador a criou.
defmodule Hello.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products) do
add :title, :string
add :description, :string
add :price, :decimal
add :views, :integer
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
Sua vez: a migration do preço
Faça na célula acima as duas mudanças do guia, antes de rodar o migrate:
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
- add :views, :integer
+ add :views, :integer, default: 0, null: false
A coluna price ganha precisão 15 e escala 6: até 15 dígitos, 6 deles depois da vírgula, o que guarda valores em dinheiro sem perder precisão nas contas. As duas colunas passam a recusar NULL, e views começa em 0. São regras garantidas pelo banco, valham para quem for que grave na tabela.
Avalie a célula de novo e rode o mix ecto.migrate:
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
O log tem as mesmas linhas que o guia mostra:
[info] == Running 20250201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward
[info] create table products
[info] == Migrated 20250201185747 in 0.0s
Trilha.descrever("products")
Com as mudanças, price aparece como numeric(15,6) e not null, e views como integer, not null, com padrão 0.
Trilha.conferir(:migration)
Trilha.solucao(:migration)
Se a migration rodou antes da mudança, a tabela já existe com as colunas antigas, e rodar o migrate de novo não muda nada: a versão já está em schema_migrations. A conferência mostra como desfazer e refazer.
Agora os módulos que o gerador criou. A seção "Entendendo o código gerado", mais abaixo, lê cada um com calma; por enquanto, avalie as células na ordem.
lib/hello/catalog/product.ex: o schema.
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :title, :string
field :description, :string
field :price, :decimal
field :views, :integer
timestamps(type: :utc_datetime)
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:title, :description, :price, :views])
|> validate_required([:title, :description, :price, :views])
end
end
lib/hello/catalog.ex: o contexto, com as seis funções que o gerador escreve para cada recurso.
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@doc """
Returns the list of products.
## Examples
iex> list_products()
[%Product{}, ...]
"""
def list_products do
Repo.all(Product)
end
@doc """
Gets a single product.
Raises `Ecto.NoResultsError` if the Product does not exist.
## Examples
iex> get_product!(123)
%Product{}
iex> get_product!(456)
** (Ecto.NoResultsError)
"""
def get_product!(id), do: Repo.get!(Product, id)
@doc """
Creates a product.
## Examples
iex> create_product(%{field: value})
{:ok, %Product{}}
iex> create_product(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a product.
## Examples
iex> update_product(product, %{field: new_value})
{:ok, %Product{}}
iex> update_product(product, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a product.
## Examples
iex> delete_product(product)
{:ok, %Product{}}
iex> delete_product(product)
{:error, %Ecto.Changeset{}}
"""
def delete_product(%Product{} = product) do
Repo.delete(product)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking product changes.
## Examples
iex> change_product(product)
%Ecto.Changeset{data: %Product{}}
"""
def change_product(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
end
end
O gerador também pede uma linha no router.
Trilha.pergunta(:rotas_resources)
lib/hello_web/router.ex: o router com a linha que o gerador pediu, no scope "/", que passa pela pipeline :browser. O guia faz essa mudança antes da migration; aqui ela vem agora, antes do controller, porque cada ~p do controller é conferido contra o router quando o módulo compila. No projeto é o mesmo arquivo do começo; aqui, uma célula nova substitui a do topo do notebook.
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
# A linha que o gerador pediu:
resources "/products", ProductController
end
end
A célula mostra dois avisos esperados: redefining module HelloWeb.Router, porque o módulo já existia, e HelloWeb.ProductController.init/1 is undefined, porque o controller vem logo abaixo. A tabela de rotas, como o mix phx.routes mostraria:
Phoenix.Router.ConsoleFormatter.format(HelloWeb.Router) |> IO.puts()
lib/hello_web/controllers/product_controller.ex: o controller gerado, com as sete actions.
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
def index(conn, _params) do
products = Catalog.list_products()
render(conn, :index, products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
render(conn, :show, product: product)
end
def edit(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
changeset = Catalog.change_product(product)
render(conn, :edit, product: product, changeset: changeset)
end
def update(conn, %{"id" => id, "product" => product_params}) do
product = Catalog.get_product!(id)
case Catalog.update_product(product, product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product updated successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, product: product, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
{:ok, _product} = Catalog.delete_product(product)
conn
|> put_flash(:info, "Product deleted successfully.")
|> redirect(to: ~p"/products")
end
end
lib/hello_web/controllers/product_html.ex e os templates em product_html/. No projeto, embed_templates "product_html/*" transforma cada arquivo product_html/NOME.html.heex em uma função NOME(assigns). Aqui as funções estão escritas na própria célula, uma por arquivo, com o conteúdo gerado. Os componentes <.header>, <.table>, <.list>, <.input> e <.button> vêm dos CoreComponents; <.form> e <.link>, do Phoenix.Component.
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
# product_html/index.html.heex
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Products
<:actions>
<.button href={~p"/products/new"}>
<.icon name="hero-plus" /> New Product
</.button>
</:actions>
</.header>
<.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
<:col :let={product} label="Title">{product.title}</:col>
<:col :let={product} label="Description">{product.description}</:col>
<:col :let={product} label="Price">{product.price}</:col>
<:col :let={product} label="Views">{product.views}</:col>
<:action :let={product}>
<div class="sr-only">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</div>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
<:action :let={product}>
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
# product_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Product {@product.id}
<:subtitle>This is a product record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/products"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
</:actions>
</.header>
<.list>
<:item title="Title">{@product.title}</:item>
<:item title="Description">{@product.description}</:item>
<:item title="Price">{@product.price}</:item>
<:item title="Views">{@product.views}</:item>
</.list>
</Layouts.app>
"""
end
# product_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
New Product
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
</Layouts.app>
"""
end
# product_html/edit.html.heex
def edit(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Edit Product {@product.id}
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
</Layouts.app>
"""
end
@doc """
Renders a product form.
The form is defined in the template at
product_html/product_form.html.heex
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :return_to, :string, default: nil
# product_html/product_form.html.heex
def product_form(assigns) do
~H"""
<.form :let={f} for={@changeset} action={@action}>
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:description]} type="text" label="Description" />
<.input field={f[:price]} type="number" label="Price" step="any" />
<.input field={f[:views]} type="number" label="Views" />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
end
end
new e edit usam o mesmo formulário, product_form, um componente de função com três atributos. O que muda entre os dois é o action do formulário: POST /products para criar, /products/ID para atualizar. Nesse segundo caso, o <.form> percebe que o changeset é de um produto que já existe e acrescenta um campo escondido _method com o valor put.
Usando a aplicação
O guia sobe o servidor com mix phx.server e visita http://localhost:4000/products. Aqui o servidor já está no ar, em http://localhost:4010/products: abra no navegador, se quiser, e repita cada passo clicando. As células abaixo fazem o mesmo caminho com Trilha.navegar/4.
A lista, ainda vazia:
Trilha.navegar("ana", :get, "/products")
O link "New Product" leva ao formulário:
Trilha.navegar("ana", :get, "/products/new")
O guia clica em "Save Product" sem preencher nada.
Trilha.pergunta(:salvar_vazio)
O navegador manda os quatro campos, mesmo vazios. A célula faz o mesmo:
Trilha.navegar("ana", :post, "/products", %{
"product" => %{"title" => "", "description" => "", "price" => "", "views" => ""}
})
Status 200 e o formulário de novo, com can't be blank embaixo de cada campo. O gerador incluiu todos os campos do schema no formulário e no validate_required, e as validações já valem.
Um detalhe do Phoenix 1.8: o formulário só mostra o erro de um campo que veio nos parâmetros. Se a requisição traz só o título, só o título ganha mensagem, mesmo que o changeset tenha erros nos quatro campos. É o que o <.input> decide com Phoenix.Component.used_input?/1: não acusar campos que a pessoa nem tocou.
Trilha.navegar("ana", :post, "/products", %{"product" => %{"title" => ""}})
Agora com dados de verdade, os do guia:
Trilha.navegar("ana", :post, "/products", %{
"product" => %{
"title" => "Metaprogramming Elixir",
"description" => "Write Less Code, Get More Done (and Have Fun!)",
"price" => "15",
"views" => "0"
}
})
Um POST com status 302 e um GET /products/1, que mostra a flash Product created successfully. e o produto: Price aparece como 15.000000, porque a coluna guarda 6 casas decimais (sem a mudança na migration, apareceria 15), e Views é 0. O id é 1 porque o banco foi recriado no começo do notebook; se você avaliou alguma célula de criação mais de uma vez, ajuste os números nas células seguintes.
O link de voltar leva à lista, que agora tem o produto:
Trilha.navegar("ana", :get, "/products")
Editar usa o mesmo formulário, preenchido:
Trilha.navegar("ana", :get, "/products/1/edit")
Trilha.navegar("ana", :put, "/products/1", %{"product" => %{"price" => "12.5"}})
Para apagar, o link "Delete" da lista tem method="delete". Um link comum só faz GET; quem transforma o clique num envio é o JavaScript do phoenix_html, servido pelo endpoint deste notebook. Ele monta um formulário com POST e um campo _method igual a delete, e o Plug.MethodOverride do endpoint troca o método antes de a requisição chegar ao router. A célula cria um segundo produto e o apaga do mesmo jeito:
{:ok, outro} =
Hello.Catalog.create_product(%{
title: "Programming Phoenix",
description: "Productive |> Reliable |> Fast",
price: 42,
views: 0
})
Trilha.navegar("ana", :post, "/products/#{outro.id}", %{"_method" => "delete"})
Agora que você viu a aplicação funcionando, é hora de olhar o código gerado.
Dar nome é difícil. Quando o domínio do negócio não é tão conhecido quanto um e-commerce, pode ser difícil traçar as fronteiras e dar nome aos contextos. Por isso os geradores aceitam ficar sem o nome do contexto: o código acima funcionaria igual com o contexto padrão,
Products, e ainda deixaria descobrir aos poucos outros recursos que pertencem a ele, como categorias ou galerias de imagens. O guia também desaconselha nomes espertos: escolha um nome claro para todos que trabalham (ou vão trabalhar) no projeto. Quando as partes do sistema ficarem claras, renomeie ou mova os recursos. Em Elixir, mover um módulo é trocar o nome dele e de quem o chama (e, por consistência, o nome do arquivo).
Entendendo o código gerado
Na documentação: Grokking generated code
Aquele mix phx.gen.html fez bastante coisa: criar, atualizar e apagar produtos já funciona. Está longe de uma aplicação completa, mas os geradores são, antes de tudo, ferramentas de aprendizado e um ponto de partida. Eles não resolvem tudo, mas mostram como o Phoenix organiza as coisas e empurram você para o jeito certo de projetar a aplicação.
Comece pelo caminho de uma requisição. Trilha.rastrear/1 mostra quem chamou quem enquanto a lista de produtos é montada:
Trilha.rastrear(fn -> Trilha.visitar("ana", :get, "/products") end)
Trilha.pergunta(:troca_de_armazenamento)
O controller não chama o Repo. A action index pede a lista com Catalog.list_products/0, e só o Catalog fala com o banco. Volte à célula do ProductController: as actions chamam Catalog.list_products/0, Catalog.create_product/1, Catalog.get_product!/1. O controller ainda não sabe como os produtos são buscados ou gravados, e esse é justamente o ponto. Ele é a interface web da aplicação: pede que algo seja feito e decide o que responder. Se um dia os produtos vierem de um motor de busca de texto em vez de uma consulta SQL, o controller não muda. E o mesmo código do contexto pode ser usado por qualquer outra interface: um channel, uma tarefa do Mix, um processo que importa um CSV.
Na action create, quando o produto é criado, o controller usa Phoenix.Controller.put_flash/3 para guardar a mensagem de sucesso e redireciona para a página do produto. Quando Catalog.create_product/1 falha, ele renderiza o template new de novo, com o changeset, de onde o formulário tira as mensagens de erro. O rastreio da criação mostra as duas requisições, a do POST e a do redirect:
Trilha.rastrear(fn ->
Trilha.visitar("ana", :post, "/products", %{
"product" => %{"title" => "Elixir in Action", "description" => "Third edition", "price" => "55", "views" => "0"}
})
end)
Agora o contexto. Na célula do Hello.Catalog, list_products/0 é só Repo.all(Product), a consulta que o notebook 07 mostrou. O nome da função diz a intenção do código, listar produtos; o detalhe de que a lista vem do PostgreSQL pelo Repo fica escondido de quem chama. É um tema que os geradores repetem: pensar onde estão as diferentes responsabilidades e esconder cada uma atrás de módulos e funções com nomes claros.
Este módulo é a API pública de tudo o que diz respeito ao catálogo. Além dos detalhes dos produtos, ele pode cuidar, por exemplo, da classificação em categorias e de variantes, como tamanhos e acabamentos.
create_product/1 tem mais documentação do que código. Por baixo, de novo, o Repo, e antes dele, o Product.changeset/2. Um contexto é um módulo como outro qualquer, e dá para usá-lo direto, como no IEx:
Hello.Catalog.list_products() |> Enum.map(& &1.title)
Hello.Catalog.create_product(%{title: "Sem preço"})
O resultado é {:error, changeset}, com description, price e views em branco. O changeset vem de Product.changeset/2, no schema.
Trilha.pergunta(:doc_false)
O schema é o que o notebook 07 gerou com mix phx.gen.schema, com uma diferença: o @doc false acima de changeset/2. A função é pública, porque o Catalog precisa chamá-la, mas não faz parte da API do contexto. Quem monta changesets fora do contexto o faz pelo contexto: Catalog.create_product/1 chama Product.changeset/2 com os dados que vieram da web, e o controller nunca chama Product.changeset/2 direto. Toda interação com os changesets de produto passa pelo Catalog.
Sua vez: outra porta para o mesmo contexto
O guia diz que o código do contexto serve a qualquer interface, como um processo que importa dados de um CSV. Escreva essa interface. Na célula abaixo, defina o módulo Hello.Importador com uma função importar/1, que recebe um texto com um produto por linha, os campos separados por ;:
Programming Phoenix;Productive |> Reliable |> Fast;42.50
;Uma linha sem título;10
Para cada linha, importar/1 cria o produto e devolve o resultado de Catalog.create_product/1: uma lista com um {:ok, product} ou {:error, changeset} por linha, na ordem das linhas. Os produtos importados começam com views igual a 0. O importador não deve chamar o Repo: quem grava é o contexto.
# lib/hello/importador.ex
Trilha.conferir(:importador)
Trilha.solucao(:importador)
Um detalhe que a conferência pode mostrar: o banco tem views com padrão 0, mas o changeset gerado continua exigindo o campo (validate_required inclui :views). As regras do banco e as do changeset são independentes; aqui, quem chama precisa mandar o valor.
Funções novas no Catalog
Na documentação: Adding Catalog functions
Os contextos são módulos dedicados que expõem e agrupam funcionalidades relacionadas. O Phoenix gera funções genéricas, como list_products e update_product, mas elas são só uma base para a lógica de negócio crescer. O guia acrescenta uma funcionalidade básica de catálogo: contar quantas vezes a página de cada produto foi vista. Num e-commerce, esse número serve para marketing, sugestões e rankings.
Daria para usar a função que já existe, algo como Catalog.update_product(product, %{views: product.views + 1}). Isso, porém, exigiria que quem chama soubesse demais sobre o catálogo, e ainda estaria sujeito a uma condição de corrida.
Trilha.pergunta(:corrida)
Intuitivamente, a gente imagina esta sequência:
- a pessoa 1 abre a página do produto, com 13 visualizações;
- a pessoa 1 grava 14;
- a pessoa 2 abre a página, com 14;
- a pessoa 2 grava 15.
Na prática, pode acontecer esta:
- a pessoa 1 abre a página, com 13;
- a pessoa 2 abre a página, com 13;
- a pessoa 1 grava 14;
- a pessoa 2 grava 14.
Com várias pessoas atualizando valores lidos antes, a contagem deixa de ser confiável.
A condição de corrida, ao vivo
A célula abaixo cria um produto e simula 100 visitas simultâneas, cada uma lendo o produto e gravando views + 1 com update_product/2. Task.async_stream/3 roda cada visita num processo separado, 20 de cada vez. Os logs de cada consulta são desligados durante a corrida (seriam 200 entradas) e religados no fim.
{:ok, contador} =
Hello.Catalog.create_product(%{title: "Contador", description: "Para a corrida", price: 1, views: 0})
nivel = Logger.level()
Logger.configure(level: :info)
1..100
|> Task.async_stream(
fn _visita ->
product = Hello.Catalog.get_product!(contador.id)
Hello.Catalog.update_product(product, %{views: product.views + 1})
end,
max_concurrency: 20
)
|> Stream.run()
Logger.configure(level: nivel)
Hello.Catalog.get_product!(contador.id).views
Cem visitas, e o contador fica bem abaixo de 100. O número exato muda a cada execução: depende de quantas visitas leram o mesmo valor antes de alguma gravar. Cada visita perdida é um UPDATE que gravou um número calculado com um valor velho.
A saída é pensar numa função que descreva o que se quer fazer. O guia escreve primeiro como gostaria de usá-la:
product = Catalog.inc_page_views(product)
Quem chama não tem dúvida sobre o que a função faz, e o incremento pode ficar dentro de uma operação atômica, sem corrida.
Sua vez: incrementar dentro do banco
Acrescente a função do guia à célula do Hello.Catalog, junto das outras, e avalie a célula:
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
Trilha.pergunta(:update_all)
A função monta uma consulta que encontra o produto pelo id e a passa a Repo.update_all/2, a função do Ecto para atualizações em lote, que também serve para alterar valores de forma atômica. inc: [views: 1] vira SET "views" = p0."views" + 1: a soma acontece dentro do banco, no mesmo comando que grava, e duas visitas simultâneas nunca leem o mesmo valor velho. O select: [:views] pede de volta o valor novo, e put_in(product.views, views) o coloca na struct que a função devolve.
Trilha.conferir(:inc_page_views)
Trilha.solucao(:inc_page_views)
Uma visita sozinha, para ver o SQL no log (se você ainda não fez o exercício, a célula dá UndefinedFunctionError):
Hello.Catalog.inc_page_views(contador)
O log mostra o UPDATE que o guia cita, com o RETURNING que traz o valor novo:
UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views"
Os parâmetros, no fim da linha do log, são o incremento (1) e o id do produto.
Agora a mesma corrida de 100 visitas, usando inc_page_views/1:
antes = Hello.Catalog.get_product!(contador.id).views
# A primeira visita roda aqui mesmo: se a função ainda não existir, o erro
# aparece nesta célula, e não em 99 processos ao mesmo tempo.
Hello.Catalog.inc_page_views(contador)
nivel = Logger.level()
Logger.configure(level: :info)
2..100
|> Task.async_stream(fn _visita -> Hello.Catalog.inc_page_views(contador) end, max_concurrency: 20)
|> Stream.run()
Logger.configure(level: nivel)
Hello.Catalog.get_product!(contador.id).views - antes
Exatamente 100. Repare que todas as visitas recebem a mesma struct contador, com o views antigo: não importa, porque a função só usa o id.
Sua vez: contar as visitas
Com a função pronta, falta usá-la. Na célula do HelloWeb.ProductController, mude a action show como o guia faz: o produto buscado passa por Catalog.inc_page_views/1, que devolve o produto atualizado, e é esse que vai para o template.
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
Trilha.conferir(:show)
Trilha.solucao(:show)
Atualize a página de um produto algumas vezes, no navegador ou com as células abaixo, e veja Views subir:
Trilha.navegar("ana", :get, "/products/1")
Trilha.navegar("ana", :get, "/products/1")
No log de cada visita aparece o mesmo UPDATE ... SET "views" = p0."views" + $1 ... RETURNING p0."views". O rastreio mostra as duas funções do contexto na mesma action:
Trilha.rastrear(fn -> Trilha.visitar("ana", :get, "/products/1") end)
Projetar com contextos dá uma base sólida para a aplicação crescer. APIs pequenas e bem definidas, que expõem a intenção do sistema, deixam o código mais fácil de manter e de reaproveitar. O próximo capítulo, no notebook 09, trata de relacionamentos dentro de um contexto.
Sem roteiro: a vitrine do Brechó vira um contexto
Agora o mesmo assunto sem passo a passo. No notebook 07, o Brechó, o mini-marketplace da trilha, ganhou a tabela produtos, e o ProdutoController falava direto com o Repo. É o código que este capítulo ensina a evitar. A tarefa é passar o Brechó para um contexto, Hello.Vitrine, e aproveitar para resolver um problema que tem a mesma forma do contador de visitas: duas pessoas comprando a mesma peça ao mesmo tempo. Num brechó, cada peça é única.
O Brechó até aqui
As células abaixo são o estado do Brechó no fim do notebook 07: a migration, o schema Hello.Produto e as rotas e o controller de /produtos.
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)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
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
O que fazer
- O contexto
Hello.Vitrine, com o schema movido para dentro dele, como o guia sugere ao falar de renomear:Hello.ProdutoviraHello.Vitrine.Produto, na mesma tabela. Aproveite e tire:vendidodocast: quem decide se uma peça foi vendida é o contexto, não um formulário. - As funções do contexto:
listar_disponiveis/0(só as peças não vendidas, da mais barata à mais cara),buscar_produto!/1ecadastrar_produto/1(devolve{:ok, produto}ou{:error, changeset}). vender/1: recebe um produto e o marca como vendido, devolvendo{:ok, produto}comvendido: true. Se a peça já tiver sido vendida, devolve{:error, :ja_vendido}. Vinte vendas simultâneas da mesma peça devem resultar em exatamente uma venda. Pense noinc_page_views/1: a verificação e a mudança precisam estar no mesmoUPDATE.- A rota
POST /produtos/:id/vender, no router abaixo, e a actionvenderno controller: vende a peça e redireciona para/produtos, com uma flash:infoquando dá certo e uma flash:errorquando a peça já foi vendida. - O
ProdutoControllerreescrito para falar só com aHello.Vitrine, semHello.Repo.GET /produtosePOST /produtoscontinuam respondendo como no 07. Na página da vitrine, cada peça ganha um link "Vender" commethod="post".
Escreva o contexto e o schema aqui. A célula fica antes do router e do controller para que, ao avaliar o notebook de cima para baixo, o controller já encontre a Vitrine compilada.
# lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
lib/hello_web/router.ex: o router com as rotas do Brechó do notebook 07. Acrescente a de venda.
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
resources "/products", ProductController
get "/produtos", ProdutoController, :index
post "/produtos", ProdutoController, :create
end
end
Os avisos redefining module HelloWeb.Router e HelloWeb.ProdutoController.init/1 is undefined são esperados, como no router dos produtos.
lib/hello_web/controllers/produto_controller.ex e produto_html.ex: como ficaram no notebook 07, falando direto com o Repo. Reescreva.
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
Para testar à mão, cadastre algumas peças e venda uma, no navegador (http://localhost:4010/produtos) ou com células como estas. Antes de a rota de venda existir, a segunda célula mostra um Phoenix.Router.NoRouteError.
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}] do
Trilha.requisicao(:post, "/produtos", %{"produto" => %{"nome" => nome, "preco_centavos" => to_string(preco)}})
end
Trilha.navegar("ana", :get, "/produtos")
Trilha.navegar("ana", :post, "/produtos/1/vender")
A conferência cria e apaga as próprias peças, vende uma delas 20 vezes ao mesmo tempo e rastreia as requisições para ver se o controller chama o Repo.
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 |
|---|---|
| As células geradas (migration, schema, contexto, web) | mix phx.gen.html Catalog Product products title:string description:string price:decimal views:integer |
Célula da migration + Trilha.migracao/2 |
priv/repo/migrations/*_create_products.exs |
Ecto.Migrator.run(..., :up, all: true) |
mix ecto.migrate |
| Célula nova do router | A linha resources "/products", ProductController em lib/hello_web/router.ex |
Phoenix.Router.ConsoleFormatter.format/1 |
mix phx.routes |
HelloWeb.ProductHTML, uma função por template |
product_html.ex com embed_templates "product_html/*" e os arquivos .heex |
Trilha.navegar/4 |
O navegador em http://localhost:4000/products |
Trilha.rastrear/1 |
Os logs Processing with ... e QUERY OK no terminal do mix phx.server |
Células que chamam Hello.Catalog direto |
iex -S mix, com alias Hello.Catalog |
| Avaliar de novo a célula do contexto | Salvar o arquivo (o mix phx.server recompila) ou recompile() no IEx |
Roteiro:
- Rode o
mix phx.gen.htmlacima e leia a lista de arquivos. Abra cada um e compare com as células deste notebook. - Faça a mudança da migration antes do
mix ecto.migrate. Se esquecer,mix ecto.rollback, ajuste e migre de novo. - Acrescente a rota, rode
mix phx.routese suba o servidor commix phx.server. Crie, edite e apague produtos em http://localhost:4000/products. - No
iex -S mix, chameHello.Catalog.list_products()eHello.Catalog.create_product(%{})e compare com o que o controller faz. - Acrescente
inc_page_views/1e mude a actionshow. Atualize a página de um produto e procure oUPDATE ... RETURNINGno terminal. - Rode a corrida no IEx, com
Task.async_stream/3, primeiro comupdate_product/2e depois cominc_page_views/1. - Abra
test/hello/catalog_test.exs: os testes gerados chamam o contexto do mesmo jeito que o controller. O módulo de testes da trilha volta a eles.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que é um contexto, por que o controller não chama o Repo, e por que inc_page_views/1 é mais do que um atalho para update_product/2. Depois compare com os pontos abaixo.
- Um contexto é um módulo comum que concentra o acesso aos dados e as regras de uma parte do sistema. É a API pública dessa parte:
Hello.Catalogpara o catálogo. - Os geradores criam o contexto (
lib/hello/catalog.ex), o schema numa pasta com o nome do contexto (lib/hello/catalog/product.ex) e, no caso dophx.gen.html, o controller e os templates emlib/hello_web/. - O primeiro argumento dos geradores é o contexto. Sem ele, o Phoenix usa o plural do recurso. Dar nome com intenção mostra como as partes do sistema se dividem; quando a divisão ainda não está clara, um nome simples e uma mudança depois também servem.
- O controller é a interface web: chama funções do contexto com nomes que dizem a intenção e decide a resposta (render, redirect, flash). Ele não sabe como os dados são guardados, e por isso não muda quando isso muda.
- O schema valida e converte com
changeset/2. O@doc falsemarca a função como detalhe interno: quem está fora do contexto passa pelo contexto. - Funções genéricas como
update_product/2são só o começo. Funções com propósito, comoinc_page_views/1, escondem detalhes e podem garantir coisas que quem chama não conseguiria, como um incremento atômico. - Ler um valor e gravar um valor calculado a partir dele é uma condição de corrida quando há acessos simultâneos. A saída é fazer a conta dentro do banco, num só comando:
Repo.update_all(consulta, inc: [views: 1]).
Próximo guia da trilha: In-context Relationships, no notebook 09, que acrescenta categorias ao catálogo.