Trilha Phoenix · Fronteiras entre 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 (com cookies e formulários), 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
@contas Hello.Accounts
@escopo Hello.Accounts.Scope
@carrinho Hello.ShoppingCart
@item Hello.ShoppingCart.CartItem
@catalogo Hello.Catalog
@vitrine Hello.Vitrine
@desejos Hello.Desejos
# --- 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 "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, 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
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%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 <> escopo(conn) <> "**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
defp escopo(conn) do
case conn.assigns do
%{current_scope: %{user: %{email: email}}} -> "**Logado como** `#{email}`\n\n"
%{current_scope: nil} -> "**Ninguém logado** (`current_scope` é `nil`)\n\n"
_ -> ""
end
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
# --- formulários -------------------------------------------------------------
@doc "Mostra os campos do primeiro formulário da página, como o navegador os enviaria."
def formulario(quem, caminho) do
{acao, campos, rotulos} = ler_formulario(quem, caminho)
rotulo_de = Map.new(rotulos, fn {rotulo, nome} -> {nome, rotulo} end)
linhas = Enum.map(campos, fn {nome, valor} -> "| `#{nome}` | #{rotulo_de[nome]} | `#{valor}` |" end)
Kino.Markdown.new(
"### O formulário de `#{caminho}`, visto por #{quem}\n\nO navegador envia `POST #{acao}` com estes campos:\n\n" <>
Enum.join(["| Campo | Rótulo na tela | Valor |", "| --- | --- | --- |" | linhas], "\n")
)
end
@doc """
Preenche o primeiro formulário da página e o envia, como um clique no botão.
`alteracoes` troca o valor dos campos, indicados pelo rótulo que aparece na tela
(`"Metaprogramming Elixir" => "3"`) ou pelo nome do campo
(`"cart[items][0][quantity]" => "3"`). Os outros campos vão com o valor que a
página trouxe.
"""
def enviar(quem, caminho, alteracoes \\ %{}) do
{acao, campos, rotulos} = ler_formulario(quem, caminho)
nomes = Map.new(campos)
alteracoes =
Map.new(alteracoes, fn {chave, valor} ->
cond do
Map.has_key?(nomes, chave) -> {chave, valor}
Map.has_key?(rotulos, chave) -> {rotulos[chave], valor}
true -> raise ArgumentError, "o formulário de #{caminho} não tem campo nem rótulo #{inspect(chave)}"
end
end)
parametros =
campos
|> Enum.map(fn {nome, valor} -> {nome, Map.get(alteracoes, nome, valor)} end)
|> URI.encode_query()
|> Plug.Conn.Query.decode()
navegar(quem, :post, acao, parametros)
end
defp ler_formulario(quem, caminho) do
{:ok, conn} = visitar(quem, :get, caminho)
case Regex.run(~r{<form\b.*?</form>}s, to_string(conn.resp_body)) do
[formulario] ->
[_, acao] = Regex.run(~r{<form\b[^>]*\saction="([^"]*)"}, formulario)
campos =
Regex.scan(~r{<input\b[^>]*>}, formulario)
|> Enum.map(fn [tag] -> {atributo(tag, "name"), atributo(tag, "value") || ""} end)
|> Enum.reject(fn {nome, _} -> nome in [nil, "_csrf_token"] end)
# O rótulo de cada campo, como o <.input> dos CoreComponents o escreve:
# <span class="label ...">Rótulo</span> logo antes do <input>.
rotulos =
Regex.scan(~r{<span[^>]*class="label[^"]*"[^>]*>\s*(.*?)\s*</span>\s*<input\b([^>]*)>}s, formulario)
|> Map.new(fn [_, rotulo, atributos] -> {desescapar(rotulo), atributo(" " <> atributos, "name")} end)
{desescapar(acao), campos, rotulos}
nil ->
mostra = texto(to_string(conn.resp_body))
dica =
if mostra =~ "Your cart is empty",
do: " O carrinho está vazio: os itens não entraram. Se \"Add to cart\" deu erro de ON CONFLICT, falta o índice único da migration de cart_items (o primeiro exercício).",
else: ""
raise "a página #{caminho} não tem formulário (status #{conn.status}). O que ela mostra: " <>
String.replace(mostra, "\n", " · ") <> "." <> dica
end
end
defp atributo(tag, nome) do
case Regex.run(~r{\s#{nome}="([^"]*)"}, tag) do
[_, valor] -> desescapar(valor)
nil -> nil
end
end
defp desescapar(texto) do
String.replace(texto, ["&", "<", ">", """, "'"], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
end)
end
@doc """
Apaga uma tabela e esquece a sua migration, para migrar de novo uma versão
editada. No projeto, o caminho é `mix ecto.rollback`.
"""
def desfazer_tabela(tabela, versao) do
sql("DROP TABLE IF EXISTS #{tabela} CASCADE")
sql("DELETE FROM schema_migrations WHERE version = $1", [versao])
Kino.Markdown.new("A tabela `#{tabela}` foi apagada e a migration `#{versao}` deixou de constar como aplicada. Avalie a célula da migration e a do `mix ecto.migrate`.")
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
%{
onde_carrinho: %{
texto:
"o carrinho precisa guardar os produtos que cada usuário escolheu, a quantidade e o preço no momento em que o item entrou. Onde fica esse código?",
opcoes: [
a: "No contexto `Catalog`, porque o carrinho guarda produtos do catálogo.",
b: "Num contexto novo, `ShoppingCart`, porque as regras do carrinho não são as regras de exibir produtos.",
c: "No `ProductController`, que já tem o produto em mãos na página dele.",
d: "Num contexto `Products`, o nome que o gerador usaria se ninguém escolhesse outro."
],
correta: :b,
explicacao:
"Exibir produtos e manter o carrinho de alguém são responsabilidades diferentes: o catálogo não precisa conhecer as regras do carrinho, e o carrinho não precisa conhecer as do catálogo. O guia cria o contexto `ShoppingCart` com dois recursos: `Cart`, o carrinho de cada usuário, e `CartItem`, cada produto dentro dele. Os dois se relacionam com o catálogo, mas estar relacionado não é motivo para morar no mesmo contexto."
},
autenticacao_autorizacao: %{
texto:
"depois do login, a aplicação sabe que quem faz a requisição é `bia@example.com`. Isso basta para garantir que a Bia só veja o próprio carrinho?",
opcoes: [
a: "Basta: o login já impede o acesso aos dados dos outros.",
b: "Não: o login diz quem é a pessoa (autenticação); falta dizer o que ela pode acessar (autorização), e cada consulta precisa filtrar pelo dono.",
c: "Basta, desde que a rota passe pelo plug `require_authenticated_user`.",
d: "Não: é preciso pedir a senha de novo a cada requisição."
],
correta: :b,
explicacao:
"`require_authenticated_user` só garante que há alguém logado. Se o contexto buscasse um carrinho só pelo `id`, a Bia poderia ler o carrinho de outra pessoa trocando o número na URL. Autorização é cada consulta filtrar pelo usuário. Os escopos do Phoenix 1.8 existem para isso: o contexto recebe o `%Scope{}` de quem chama e filtra por ele."
},
escopo_padrao: %{
texto:
"o `phx.gen.auth` registrou o escopo `user` como padrão em `config/config.exs`. Depois dele, o guia roda `mix phx.gen.context ShoppingCart Cart carts`, sem nenhum campo. Que colunas a tabela `carts` vai ter?",
opcoes: [
a: "Só `id`, `inserted_at` e `updated_at`, porque o comando não pediu campos.",
b: "`id`, `user_id` (chave estrangeira para `users`) e os timestamps.",
c: "`id`, uma coluna de texto com o e-mail do dono, e os timestamps.",
d: "Nenhuma: o gerador recusa um schema sem campos."
],
correta: :b,
explicacao:
"Com um escopo padrão configurado, os geradores acrescentam o campo do escopo (`schema_key: :user_id`, do tipo `schema_type: :id`, apontando para `schema_table: :users`) e escrevem todas as funções do contexto filtrando por ele. Para gerar sem escopo, passa-se `--no-scope`; para usar outro escopo, `--scope NOME`."
},
no_scope: %{
texto: "o `CartItem` foi gerado com `--no-scope`, sem `user_id`. Como saber de quem é um item?",
opcoes: [
a: "Não dá: qualquer usuário pode alterar qualquer item.",
b: "Pelo carrinho: o item pertence a um `Cart`, o `Cart` tem `user_id`, e as funções do contexto conferem o dono do carrinho antes de mexer nos itens.",
c: "Pelo produto: cada produto tem um dono.",
d: "Pela sessão: o Phoenix guarda os itens de cada usuário no cookie."
],
correta: :b,
explicacao:
"O escopo vale para o recurso principal, o carrinho. Os itens chegam até o usuário por `cart_id`. Repetir `user_id` em cada item duplicaria a informação e abriria espaço para um item apontar para o carrinho de um usuário e para o `user_id` de outro. As funções que mexem em itens recebem o carrinho e conferem que ele é do usuário do escopo."
},
join_ou_api: %{
texto: "o `ShoppingCart` precisa do título e do preço dos produtos, que pertencem ao `Catalog`. Que caminho o guia escolhe?",
opcoes: [
a: "Copiar título e preço para a tabela `cart_items`, para não depender do catálogo.",
b: "Chamar `Catalog.get_product!/1` para cada item e juntar os dados em Elixir.",
c: "Um `belongs_to :product, Hello.Catalog.Product` no `CartItem`, e joins no banco para trazer os produtos junto com o carrinho.",
d: "Proibir qualquer referência entre contextos."
],
correta: :c,
explicacao:
"O guia discute duas opções: expor funções no `Catalog` e juntar os dados à mão, ou usar joins do banco. Como o carrinho depende de verdade dos produtos (um item sem produto não existe), ele aceita o acoplamento de dados: o `CartItem` aponta para `Catalog.Product`, e `get_cart/1` traz tudo com joins. O acoplamento fica no schema. As regras continuam separadas: o `ShoppingCart` não sabe como o catálogo funciona."
},
price_cast: %{
texto:
"o changeset gerado do `CartItem` faz `cast` de `price_when_carted` e de `quantity`. O que pode dar errado se `price_when_carted` continuar no `cast`?",
opcoes: [
a: "Nada: o formulário do carrinho não tem campo de preço.",
b: "Quem envia a requisição pode mandar `cart[items][0][price_when_carted]=0.01` e gravar o preço que quiser, mesmo sem campo no formulário.",
c: "O preço seria convertido para inteiro.",
d: "O Ecto recusaria o changeset, porque o preço já existe."
],
correta: :b,
explicacao:
"O formulário é só o que o navegador costuma mandar; qualquer pessoa pode montar uma requisição com outros campos. O `cast/3` aceita todo campo da lista que vier nos parâmetros. Por isso o guia tira `price_when_carted` do `cast` e do `validate_required`: o preço é posto pelo código, a partir do produto (`%CartItem{price_when_carted: product.price}`), nunca pela entrada do usuário."
},
upsert: %{
texto:
"o carrinho da Bia já tem 1 unidade do produto 1. Ela clica de novo em \"Add to cart\" no mesmo produto. O que `add_item_to_cart/3` faz?",
opcoes: [
a: "Insere uma segunda linha em `cart_items`, com quantidade 1.",
b: "Devolve `{:error, changeset}`, porque o produto já está no carrinho.",
c: "Tenta o insert; o índice único de `[cart_id, product_id]` acusa o conflito, e o banco soma 1 à quantidade da linha que já existe.",
d: "Apaga o item e insere de novo."
],
correta: :c,
explicacao:
"É um upsert. `on_conflict: [inc: [quantity: 1]]` diz o que fazer quando o insert esbarra numa restrição, e `conflict_target: [:cart_id, :product_id]` diz qual restrição: o índice único da migration. Tudo vira um único `INSERT ... ON CONFLICT ... DO UPDATE`, atômico, sem ler antes de escrever. Sem o índice único, o PostgreSQL recusa o comando."
},
true_match: %{
texto:
"`update_cart/3` começa com `true = cart.user_id == scope.user.id`. O que acontece se algum código chamar a função com o escopo do Caio e o carrinho da Bia?",
opcoes: [
a: "Devolve `{:error, :unauthorized}`.",
b: "Atualiza o carrinho da Bia normalmente.",
c: "Levanta `MatchError`: `false` não casa com `true`, e o processo que chamou falha.",
d: "Devolve `nil`."
],
correta: :c,
explicacao:
"É uma asserção, não uma checagem com resposta. O código da aplicação nunca deveria chamar a função com o carrinho de outro dono; se chamar, é um bug, e é melhor falhar alto do que seguir em frente. Numa requisição, a exceção vira erro 500. Para o caso esperado, o de alguém pedir um recurso que não é seu, o contexto usa consultas filtradas pelo escopo, como `Repo.get_by!(Cart, id: id, user_id: scope.user.id)`, que levanta `Ecto.NoResultsError`, e essa exceção vira 404."
},
preco_atual: %{
texto:
"a Bia pôs 3 unidades de \"Metaprogramming Elixir\" no carrinho a $15. Depois a loja muda o preço para $20. O que a página do carrinho mostra no total?",
opcoes: [
a: "$45.00: o carrinho guardou o preço em `price_when_carted`.",
b: "$60.00: `total_item_price/1` multiplica o preço atual do produto (`item.product.price`) pela quantidade.",
c: "Um erro, porque os preços não batem.",
d: "$15.00, o preço de uma unidade."
],
correta: :b,
explicacao:
"O guia grava `price_when_carted`, mas as funções de total usam `item.product.price`, o preço de agora, que vem do join com `products`. O preço guardado serve para perceber a mudança, como o começo do guia anuncia; o exercício abaixo usa esse dado para avisar quem está comprando."
}
}
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
%{
migracao_itens: ~S'''
defmodule Hello.Repo.Migrations.CreateCartItems do
use Ecto.Migration
def change do
create table(:cart_items) do
add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :cart_id, references(:carts, on_delete: :delete_all)
add :product_id, references(:products, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:cart_items, [:product_id])
create unique_index(:cart_items, [:cart_id, :product_id])
end
end
Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)
# Se a versão gerada já tinha rodado, desfaça antes de migrar de novo:
# Trilha.desfazer_tabela("cart_items", 20250205213410)
''',
item_changeset: ~S'''
defmodule Hello.ShoppingCart.CartItem do
use Ecto.Schema
import Ecto.Changeset
schema "cart_items" do
field :price_when_carted, :decimal
field :quantity, :integer
belongs_to :cart, Hello.ShoppingCart.Cart
belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart_item, attrs) do
cart_item
|> cast(attrs, [:quantity])
|> validate_required([:quantity])
|> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
end
end
''',
botao: ~S'''
# Em show/1, dentro do slot :actions do <.header>, depois do botão "Edit product":
<.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
Add to cart
</.button>
''',
update_cart: ~S'''
def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
true = cart.user_id == scope.user.id
changeset =
cart
|> Cart.changeset(attrs, scope)
|> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)
Repo.transact(fn ->
with {:ok, cart} <- Repo.update(changeset),
{_count, _cart_items} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)) do
{:ok, cart}
end
end)
|> case do
{:ok, cart} ->
broadcast_cart(scope, {:updated, cart})
{:ok, cart}
{:error, reason} ->
{:error, reason}
end
end
''',
preco_mudou: ~S'''
defmodule HelloWeb.CartHTML do
use HelloWeb, :html
alias Hello.ShoppingCart
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
def price_changed?(item), do: not Decimal.equal?(item.price_when_carted, item.product.price)
# cart_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
My Cart
<:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
</.header>
<div :if={@cart.items !== []}>
<.form :let={f} for={@changeset} action={~p"/cart"}>
<.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
<.input field={item_form[:quantity]} type="number" label={item.product.title} />
{currency_to_str(ShoppingCart.total_item_price(item))}
<span :if={price_changed?(item)}>(was {currency_to_str(item.price_when_carted)})</span>
</.inputs_for>
<.button>Update cart</.button>
</.form>
<b>Total</b>: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
</div>
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>
"""
end
end
''',
brecho: ~S'''
# priv/repo/migrations/20250322000000_create_desejos.exs
defmodule Hello.Repo.Migrations.CreateDesejos do
use Ecto.Migration
def change do
create table(:desejos) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :produto_id, references(:produtos, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create unique_index(:desejos, [:user_id, :produto_id])
end
end
Trilha.migracao(20250322000000, Hello.Repo.Migrations.CreateDesejos)
# lib/hello/desejos/desejo.ex e lib/hello/desejos.ex
defmodule Hello.Desejos.Desejo do
use Ecto.Schema
schema "desejos" do
belongs_to :user, Hello.Accounts.User
belongs_to :produto, Hello.Vitrine.Produto
timestamps(type: :utc_datetime)
end
end
defmodule Hello.Desejos do
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.Scope
alias Hello.Desejos.Desejo
def desejar(%Scope{} = scope, produto_id) do
Repo.insert(%Desejo{user_id: scope.user.id, produto_id: String.to_integer("#{produto_id}")},
on_conflict: :nothing,
conflict_target: [:user_id, :produto_id]
)
end
def listar(%Scope{} = scope) do
Repo.all(
from d in Desejo,
where: d.user_id == ^scope.user.id,
order_by: d.inserted_at,
preload: :produto
)
end
def remover(%Scope{} = scope, produto_id) do
{quantos, _} =
Repo.delete_all(from d in Desejo, where: d.user_id == ^scope.user.id and d.produto_id == ^produto_id)
{:ok, quantos}
end
end
# No router, num scope com pipe_through [:browser, :require_authenticated_user]:
# get "/desejos", DesejoController, :index
# post "/desejos", DesejoController, :create
# lib/hello_web/controllers/desejo_controller.ex e desejo_html.ex
defmodule HelloWeb.DesejoController do
use HelloWeb, :controller
alias Hello.Desejos
def index(conn, _params) do
render(conn, :index, desejos: Desejos.listar(conn.assigns.current_scope))
end
def create(conn, %{"produto_id" => produto_id}) do
{:ok, _} = Desejos.desejar(conn.assigns.current_scope, produto_id)
conn
|> put_flash(:info, "Guardado na sua lista de desejos.")
|> redirect(to: ~p"/desejos")
end
end
defmodule HelloWeb.DesejoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Minha lista de desejos
<:subtitle :if={@desejos == []}>Nenhuma peça guardada</:subtitle>
</.header>
<ul>
<li :for={desejo <- @desejos}>{desejo.produto.nome} · {desejo.produto.preco_centavos} centavos</li>
</ul>
</Layouts.app>
"""
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
end
defp verificacoes do
%{
migracao_itens: [
{"A tabela `cart_items` existe, com `price_when_carted`, `quantity`, `cart_id` e `product_id`.",
fn -> colunas("cart_items", ~w(price_when_carted quantity cart_id product_id)) end},
{"`price_when_carted` é `numeric(15,6)` e não aceita `NULL`.",
fn ->
case sql(
"SELECT numeric_precision, numeric_scale, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'cart_items' AND column_name = 'price_when_carted'"
).rows do
[[15, 6, "NO"]] -> :ok
[[nil, nil, _]] -> {:erro, "A coluna é um `numeric` sem precisão nem escala. Use `precision: 15, scale: 6, null: false`. " <> refazer_itens()}
[[p, s, n]] -> {:erro, "A coluna está com precisão `#{p}`, escala `#{s}` e nullable `#{n}`. " <> refazer_itens()}
end
end},
{"Apagar um carrinho ou um produto apaga os itens dele (`on_delete: :delete_all`).",
fn ->
regras =
sql("""
SELECT a.attname, c.confdeltype::text FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conrelid = 'cart_items'::regclass AND c.contype = 'f'
""").rows
|> Map.new(fn [coluna, regra] -> {coluna, regra} end)
faltando = for coluna <- ["cart_id", "product_id"], regras[coluna] != "c", do: "`#{coluna}`"
if faltando == [],
do: :ok,
else: {:erro, "As chaves #{Enum.join(faltando, " e ")} não apagam em cascata. Use `on_delete: :delete_all` no `references`. " <> refazer_itens()}
end},
{"O índice único de `[:cart_id, :product_id]` existe.",
fn ->
if Enum.any?(indices("cart_items"), &(&1 =~ "UNIQUE" and &1 =~ "(cart_id, product_id)")),
do: :ok,
else: {:erro, "Falta `create unique_index(:cart_items, [:cart_id, :product_id])`. " <> refazer_itens()}
end},
{"O índice só de `cart_id` saiu.",
fn ->
if Enum.any?(indices("cart_items"), &(&1 =~ "cart_items_cart_id_index")),
do: {:erro, "O índice `cart_items_cart_id_index` continua lá. O índice único começa por `cart_id` e já serve às buscas por essa coluna. " <> refazer_itens()},
else: :ok
end}
],
item_changeset: [
{"`price_when_carted` vindo dos parâmetros é ignorado.",
fn ->
cs = changeset_item(%{"quantity" => "2", "price_when_carted" => "0.01"})
if Map.has_key?(cs.changes, :price_when_carted),
do: {:erro, "O changeset aceitou `price_when_carted: 0.01`. Tire o campo da lista do `cast/3`."},
else: :ok
end},
{"Um item só com `quantity` é válido.",
fn ->
cs = changeset_item(%{"quantity" => "2"})
if cs.valid?,
do: :ok,
else: {:erro, "O changeset ficou inválido: `#{inspect(cs.errors)}`. Tire `price_when_carted` também do `validate_required/3`."}
end},
{"`quantity` igual a 0 e a 99 é aceito.",
fn ->
invalidos = for q <- ["0", "99"], not changeset_item(%{"quantity" => q}).valid?, do: q
if invalidos == [],
do: :ok,
else: {:erro, "Recusou `quantity` #{Enum.join(invalidos, " e ")}. O limite de baixo inclui o 0 (`greater_than_or_equal_to: 0`)."}
end},
{"`quantity` igual a 100 e a -1 é recusado por `validate_number/3`.",
fn ->
aceitos =
for q <- ["100", "-1"],
match?(nil, changeset_item(%{"quantity" => q}).errors[:quantity]),
do: q
if aceitos == [],
do: :ok,
else: {:erro, "Aceitou `quantity` #{Enum.join(aceitos, " e ")}. Use `validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)`."}
end}
],
botao: [
{"A página `GET /products/1` tem um link \"Add to cart\".",
fn ->
case visitar("conferencia-anonimo", :get, "/products/1") do
{:ok, %{status: 200} = conn} ->
if to_string(conn.resp_body) =~ "Add to cart",
do: :ok,
else: {:erro, "A página não tem o texto \"Add to cart\". Acrescente o botão em `show/1` e avalie a célula do `ProductHTML` desta seção."}
{:ok, conn} ->
{:erro, "A página respondeu `#{conn.status}`. O produto 1 existe? A célula dos seeds roda depois do `Trilha.recomecar()`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"O link envia `POST /cart_items?product_id=1`.",
fn ->
{:ok, conn} = visitar("conferencia-anonimo", :get, "/products/1")
case Regex.run(~r{<a\b[^>]*>\s*Add to cart}, to_string(conn.resp_body)) do
[tag] ->
cond do
not (tag =~ ~s(data-method="post")) -> {:erro, "O link não tem `method=\"post\"`; sem ele, o clique faz um `GET`."}
not (tag =~ ~s(data-to="/cart_items?product_id=1")) -> {:erro, "O link não aponta para `/cart_items?product_id=1`. Use `href={~p\"/cart_items?product_id=\#{@product.id}\"}`."}
true -> :ok
end
nil ->
{:erro, "O texto \"Add to cart\" não está dentro de um link. Use `<.button href={...} method=\"post\">`."}
end
end}
],
update_cart: [
{"`PUT /cart` grava a nova quantidade de um item.",
fn ->
limpando(fn ->
{quem, escopo} = entrar("conferencia-a")
{:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
item = item_no_carrinho(escopo, 1)
{:ok, _} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "4"}]))
case quantidade(item.id) do
4 -> :ok
q -> {:erro, "A quantidade continua `#{inspect(q)}`. O `update_cart/3` gerado só faz `cast` dos campos do próprio `Cart`; use `cast_assoc(:items, with: &CartItem.changeset/2)`."}
end
end)
end},
{"Quantidade 0 tira o item do carrinho.",
fn ->
limpando(fn ->
{quem, escopo} = entrar("conferencia-a")
{:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
item = item_no_carrinho(escopo, 1)
{:ok, _} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "0"}]))
case quantidade(item.id) do
nil -> :ok
0 -> {:erro, "O item ficou na tabela com quantidade 0. Apague os itens zerados com `Repo.delete_all/1`, na mesma transação do `Repo.update/1`."}
q -> {:erro, "A quantidade continua `#{q}`. O `update_cart/3` precisa de `cast_assoc(:items, ...)`."}
end
end)
end},
{"Quantidade 100 não muda nada e a página mostra a flash de erro.",
fn ->
limpando(fn ->
{quem, escopo} = entrar("conferencia-a")
{:ok, _} = visitar(quem, :post, "/cart_items?product_id=1")
item = item_no_carrinho(escopo, 1)
{:ok, conn} = visitar(quem, :put, "/cart", parametros_do_carrinho([{item.id, "100"}]))
flash = Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error)
cond do
quantidade(item.id) == 100 -> {:erro, "A quantidade 100 foi gravada. Falta a validação do exercício \"o changeset do item\"."}
flash != "There was an error updating your cart" -> {:erro, "A página não mostrou a flash de erro. A action `update` trata o `{:error, _}`?"}
true -> :ok
end
end)
end}
],
preco_mudou: [
{"Sem mudança de preço, a página do carrinho não mostra \"(was\".",
fn ->
limpando(fn ->
{quem, _escopo} = entrar("conferencia-a")
produto = produto_de_conferencia("10")
{:ok, _} = visitar(quem, :post, "/cart_items?product_id=#{produto.id}")
{:ok, conn} = visitar(quem, :get, "/cart")
if to_string(conn.resp_body) =~ "(was",
do: {:erro, "O aviso aparece mesmo com o preço igual. Compare com `Decimal.equal?/2`: `10.000000` e `10` são o mesmo valor."},
else: :ok
end)
end},
{"Depois que o preço sobe de $10 para $12, a página mostra \"(was $10.00)\".",
fn ->
limpando(fn ->
{quem, _escopo} = entrar("conferencia-a")
produto = produto_de_conferencia("10")
{:ok, _} = visitar(quem, :post, "/cart_items?product_id=#{produto.id}")
sql("UPDATE products SET price = 12 WHERE id = $1", [produto.id])
{:ok, conn} = visitar(quem, :get, "/cart")
corpo = texto(to_string(conn.resp_body))
cond do
corpo =~ ~r/\(was\s*\$10\.00\)/ -> :ok
corpo =~ "was" -> {:erro, "Apareceu um aviso, mas não `(was $10.00)`. O texto da página ficou assim:\n\n `#{String.replace(corpo, "\n", " · ")}`"}
true -> {:erro, "A página não avisa a mudança. Mostre `(was {currency_to_str(item.price_when_carted)})` quando o preço guardado for diferente de `item.product.price`."}
end
end)
end}
],
brecho: [
{"A migration `20250322000000` está registrada e a tabela `desejos` tem `user_id`, `produto_id` e os timestamps.",
fn ->
if Enum.any?(migracoes(), fn {versao, _} -> versao == 20250322000000 end),
do: colunas("desejos", ~w(user_id produto_id inserted_at updated_at)),
else: {:erro, "Registre a migration com `Trilha.migracao(20250322000000, Hello.Repo.Migrations.CreateDesejos)` e rode o `mix ecto.migrate`."}
end},
{"Um índice único impede o mesmo produto duas vezes na lista de alguém.",
fn ->
if Enum.any?(indices("desejos"), &(&1 =~ "UNIQUE" and &1 =~ "(user_id, produto_id)")),
do: :ok,
else: {:erro, "Falta `create unique_index(:desejos, [:user_id, :produto_id])`."}
end},
{"`Hello.Desejos` define `desejar/2`, `listar/1` e `remover/2`.",
fn -> exporta(@desejos, desejar: 2, listar: 1, remover: 2) end},
{"Desejar duas vezes o mesmo produto guarda uma linha só, e `listar/1` traz o produto carregado.",
fn ->
limpando(fn ->
{_quem, escopo} = entrar("conferencia-a")
produto = peca_de_conferencia("Conferência Casaco")
{:ok, _} = apply(@desejos, :desejar, [escopo, produto.id])
{:ok, _} = apply(@desejos, :desejar, [escopo, produto.id])
case apply(@desejos, :listar, [escopo]) do
[%{produto: %{nome: "Conferência Casaco"}}] -> :ok
[%{produto: %Ecto.Association.NotLoaded{}}] -> {:erro, "O produto não veio carregado. Use `preload: :produto` na consulta."}
lista when is_list(lista) -> {:erro, "`listar/1` devolveu #{length(lista)} desejo(s): `#{inspect(lista, limit: 5)}`."}
outro -> {:erro, "`listar/1` devolveu `#{inspect(outro, limit: 5)}`, e não uma lista."}
end
end)
end},
{"A lista de um usuário não mostra os desejos de outro, e `remover/2` tira só do próprio.",
fn ->
limpando(fn ->
{_a, escopo_a} = entrar("conferencia-a")
{_b, escopo_b} = entrar("conferencia-b")
produto = peca_de_conferencia("Conferência Saia")
{:ok, _} = apply(@desejos, :desejar, [escopo_a, produto.id])
{:ok, _} = apply(@desejos, :desejar, [escopo_b, produto.id])
apply(@desejos, :remover, [escopo_b, produto.id])
cond do
length(apply(@desejos, :listar, [escopo_a])) != 1 -> {:erro, "A lista do usuário A deveria ter 1 desejo depois que B removeu o dele."}
apply(@desejos, :listar, [escopo_b]) != [] -> {:erro, "O desejo de B continua na lista dele depois de `remover/2`."}
true -> :ok
end
end)
end},
{"Sem login, `POST /desejos?produto_id=…` leva para a página de login.",
fn ->
limpando(fn ->
produto = peca_de_conferencia("Conferência Boné")
case visitar("conferencia-anonimo", :post, "/desejos?produto_id=#{produto.id}") do
{:ok, %{request_path: "/users/log-in"}} -> :ok
{:ok, conn} -> {:erro, "A última página foi `#{conn.request_path}`, com status `#{conn.status}`. As rotas precisam passar por `:require_authenticated_user`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end)
end},
{"Com login, `POST /desejos?produto_id=…` redireciona para `GET /desejos`, que lista a peça só para quem a guardou.",
fn ->
limpando(fn ->
{a, _} = entrar("conferencia-a")
{b, _} = entrar("conferencia-b")
produto = peca_de_conferencia("Conferência Lenço")
with {:ok, conn_a} <- visitar(a, :post, "/desejos?produto_id=#{produto.id}"),
{:ok, conn_b} <- visitar(b, :get, "/desejos") do
cond do
conn_a.request_path != "/desejos" -> {:erro, "Depois do `POST`, a última página foi `#{conn_a.request_path}`. Redirecione para `~p\"/desejos\"`."}
not (to_string(conn_a.resp_body) =~ "Conferência Lenço") -> {:erro, "A página `/desejos` não mostra o nome da peça guardada."}
to_string(conn_b.resp_body) =~ "Conferência Lenço" -> {:erro, "A lista de outro usuário mostrou a peça. Filtre pelo escopo."}
true -> :ok
end
else
{:erro, erro} -> {:erro, explicar(erro)}
end
end)
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 indices(tabela), do: coluna("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1", [tabela])
defp refazer_itens do
"Se a migration já tinha rodado antes da mudança, avalie `Trilha.desfazer_tabela(\"cart_items\", 20250205213410)` e depois a célula da migration e a do `mix ecto.migrate`."
end
defp existe?(tabela), do: coluna("SELECT to_regclass($1)::text", ["public." <> tabela]) != [nil]
defp changeset_item(params), do: apply(@item, :changeset, [struct(@item), params])
# Um usuário só da conferência, logado pela rota de login, como a Bia e o Caio.
defp entrar(quem) do
email = quem <> "@example.com"
{:ok, _} = visitar(quem, :post, "/users/log-in", %{"user" => %{"email" => email}})
usuario = apply(@contas, :get_user_by_email, [email])
{quem, apply(@escopo, :for_user, [usuario])}
end
defp item_no_carrinho(escopo, produto_id) do
carrinho = apply(@carrinho, :get_cart, [escopo])
Enum.find(carrinho.items, &(&1.product_id == produto_id)) || raise "o produto #{produto_id} não entrou no carrinho"
end
defp quantidade(item_id) do
case coluna("SELECT quantity FROM cart_items WHERE id = $1", [item_id]) do
[q] -> q
[] -> nil
end
end
defp parametros_do_carrinho(itens) do
itens =
itens
|> Enum.with_index()
|> Map.new(fn {{id, quantidade}, i} -> {to_string(i), %{"id" => to_string(id), "quantity" => quantidade}} end)
%{"cart" => %{"items" => itens}}
end
defp produto_de_conferencia(preco) do
{:ok, produto} =
apply(@catalogo, :create_product, [
%{"title" => "Conferência Livro", "description" => "Produto da conferência", "price" => preco, "views" => "0"}
])
produto
end
defp peca_de_conferencia(nome) do
{:ok, peca} = apply(@vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: 1000}])
peca
end
defp limpando(teste) do
teste.()
after
limpar_conferencia()
end
# Apaga tudo o que as conferências criaram, na ordem que as chaves estrangeiras exigem.
defp limpar_conferencia do
usuarios = "SELECT id FROM users WHERE email LIKE 'conferencia-%'"
comandos = [
{"cart_items", "DELETE FROM cart_items WHERE cart_id IN (SELECT id FROM carts WHERE user_id IN (#{usuarios}))"},
{"cart_items", "DELETE FROM cart_items WHERE product_id IN (SELECT id FROM products WHERE title LIKE 'Conferência%')"},
{"carts", "DELETE FROM carts WHERE user_id IN (#{usuarios})"},
{"desejos", "DELETE FROM desejos WHERE user_id IN (#{usuarios})"},
{"desejos", "DELETE FROM desejos WHERE produto_id IN (SELECT id FROM produtos WHERE nome LIKE 'Conferência%')"},
{"product_categories", "DELETE FROM product_categories WHERE product_id IN (SELECT id FROM products WHERE title LIKE 'Conferência%')"},
{"products", "DELETE FROM products WHERE title LIKE 'Conferência%'"},
{"produtos", "DELETE FROM produtos WHERE nome LIKE 'Conferência%'"},
{"users", "DELETE FROM users WHERE email LIKE 'conferencia-%'"}
]
for {tabela, comando} <- comandos, existe?(tabela) do
try do
sql(comando)
rescue
_ -> :ignorado
end
end
:ok
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."
true ->
"`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
end
defp explicar(%Postgrex.Error{postgres: %{code: :invalid_column_reference}}) do
"O PostgreSQL recusou o `ON CONFLICT`: falta o índice único de `[:cart_id, :product_id]` em `cart_items`. Volte ao exercício da migration dos itens."
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 acompanha o guia Cross-context Boundaries, o quarto dos guias de modelagem de dados da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. Os notebooks 08 e 09 montaram o contexto Catalog, com produtos e categorias. Agora a loja ganha um carrinho de compras, que mora num contexto novo, ShoppingCart, e que depende de dois outros: do Catalog, porque cada item é um produto, e de Accounts, porque cada carrinho é de um usuário.
O guia começa gerando a autenticação com mix phx.gen.auth. Um notebook não roda geradores, e a autenticação completa (senhas, links de login por e-mail, tokens) é assunto de um módulo próprio da trilha. Por isso este notebook traz uma autenticação reduzida, com os mesmos nomes de módulos e de funções que o gerador cria. A seção "Autenticação" explica exatamente o que ficou de fora.
As convenções são as de sempre: uma célula equivale a um arquivo, com o caminho em negrito acima dela; as perguntas "Antes de continuar" vêm antes da explicação; os erros fazem parte do conteúdo. Três ajudantes do módulo Trilha fazem o papel do navegador:
Trilha.navegar(quem, método, caminho, parâmetros)faz uma requisição como um navegador faria. Cada visitante ("bia","caio") tem os seus próprios cookies, então quem fez login continua logado nas requisições seguintes. Os redirects são seguidos, e o resultado mostra cada passo, a flash, quem está logado e o texto da página final.Trilha.formulario(quem, caminho)mostra os campos que o formulário de uma página enviaria.Trilha.enviar(quem, caminho, alterações)preenche esse formulário e o envia, como um clique no botão.
Você também pode abrir as páginas no navegador, em http://localhost:4010. Lá, faça login em http://localhost:4010/users/log-in com qualquer e-mail.
Este notebook usa o PostgreSQL, como o 07. 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
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: o router do notebook 09, com as rotas dos produtos.
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
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
O catálogo dos notebooks 08 e 09
O contexto Catalog no estado em que o guia anterior, In-context Relationships, o deixou: produtos com categorias, o contador de visitas e as páginas geradas pelo mix phx.gen.html. Para encurtar, as funções geradas aparecem sem os @doc, que você leu no notebook 08.
priv/repo/migrations/: as três migrations do catálogo.
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
defmodule Hello.Repo.Migrations.CreateCategories do
use Ecto.Migration
def change do
create table(:categories) do
add :title, :string
timestamps(type: :utc_datetime)
end
create unique_index(:categories, [:title])
end
end
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
create table(:product_categories, primary_key: false) do
add :product_id, references(:products, on_delete: :delete_all)
add :category_id, references(:categories, on_delete: :delete_all)
end
create index(:product_categories, [:product_id])
create unique_index(:product_categories, [:category_id, :product_id])
end
end
Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
Trilha.migracao(20250203192325, Hello.Repo.Migrations.CreateCategories)
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)
lib/hello/catalog/category.ex e lib/hello/catalog/product.ex
defmodule Hello.Catalog.Category do
use Ecto.Schema
import Ecto.Changeset
schema "categories" do
field :title, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(category, attrs) do
category
|> cast(attrs, [:title])
|> validate_required([:title])
|> unique_constraint(:title)
end
end
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
alias Hello.Catalog.Category
schema "products" do
field :title, :string
field :description, :string
field :price, :decimal
field :views, :integer
many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete
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
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
alias Hello.Catalog.Category
def list_products do
Repo.all(Product)
end
def get_product!(id) do
Product
|> Repo.get!(id)
|> Repo.preload(:categories)
end
def create_product(attrs) do
%Product{}
|> change_product(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
|> change_product(attrs)
|> Repo.update()
end
def delete_product(%Product{} = product) do
Repo.delete(product)
end
def change_product(%Product{} = product, attrs \\ %{}) do
categories = list_categories_by_id(attrs["category_ids"])
product
|> Repo.preload(:categories)
|> Product.changeset(attrs)
|> Ecto.Changeset.put_assoc(:categories, categories)
end
def list_categories_by_id(nil), do: []
def list_categories_by_id(category_ids) do
Repo.all(from c in Category, where: c.id in ^category_ids)
end
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
def list_categories do
Repo.all(Category)
end
def get_category!(id), do: Repo.get!(Category, id)
def create_category(attrs) do
%Category{}
|> Category.changeset(attrs)
|> Repo.insert()
end
def update_category(%Category{} = category, attrs) do
category
|> Category.changeset(attrs)
|> Repo.update()
end
def delete_category(%Category{} = category) do
Repo.delete(category)
end
def change_category(%Category{} = category, attrs \\ %{}) do
Category.changeset(category, attrs)
end
end
lib/hello_web/controllers/product_controller.ex
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 =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
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 de product_html/, um por função.
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_change(:categories, [])
|> Enum.map(& &1.data.id)
for cat <- Hello.Catalog.list_categories() do
[key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
end
# 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>
<:item title="Categories">
<ul>
<li :for={cat <- @product.categories}>{cat.title}</li>
</ul>
</: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" />
<.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
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()
Crie as tabelas do catálogo (é o mix ecto.migrate):
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
priv/repo/seeds.exs: as categorias do notebook 09 e dois produtos do guia. O segundo já entra com duas categorias.
for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
{:ok, _} = Hello.Catalog.create_category(%{title: title})
end
{:ok, _} =
Hello.Catalog.create_product(%{
"title" => "Metaprogramming Elixir",
"description" => "Write Less Code, Get More Done (and Have Fun!)",
"price" => "15",
"views" => "0"
})
{:ok, _} =
Hello.Catalog.create_product(%{
"title" => "Elixir Flashcards",
"description" => "Flash card set for the Elixir programming language",
"price" => "5",
"views" => "0",
"category_ids" => ["4", "5"]
})
Trilha.consultar("SELECT id, title, price, views FROM products ORDER BY id")
Um contexto novo para o carrinho
Na documentação: Cross-context Boundaries
O catálogo já exibe produtos. O passo seguinte de uma loja é deixar alguém pôr produtos num carrinho. Para isso a aplicação precisa guardar informação nova: quais produtos cada usuário escolheu, quantos de cada um e o preço no momento em que o item entrou no carrinho. Guardar esse preço permite perceber, mais tarde, que ele mudou.
Trilha.pergunta(:onde_carrinho)
O guia lista quatro requisitos para o contexto ShoppingCart:
- adicionar produtos ao carrinho do usuário, a partir da página do produto;
- guardar o preço do produto no momento em que ele entrou no carrinho;
- guardar e atualizar a quantidade de cada item;
- calcular e mostrar a soma dos preços.
Daí saem dois recursos: Cart, o carrinho de cada usuário, e CartItem, cada produto dentro dele.
Autenticação
Na documentação: Adding authentication
Quase tudo no carrinho depende de saber quem é o usuário: cada um deve ver e mudar só o próprio carrinho. O guia gera um sistema de autenticação com o gerador do Phoenix:
mix phx.gen.auth Accounts User users
Os argumentos seguem a convenção dos outros geradores: o contexto (Accounts), o schema (User) e a tabela (users). Antes de criar os arquivos, o gerador pergunta:
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Yn]
O guia responde n, porque a loja usa controllers. Num projeto com Phoenix 1.8.16, essa resposta cria estes arquivos:
lib/hello/accounts.ex o contexto Accounts
lib/hello/accounts/user.ex o schema User: e-mail, hash da senha, confirmação
lib/hello/accounts/user_token.ex tokens de sessão e dos links enviados por e-mail
lib/hello/accounts/user_notifier.ex os e-mails de login e de troca de endereço
lib/hello/accounts/scope.ex o escopo, assunto desta seção
lib/hello_web/user_auth.ex os plugs de autenticação
lib/hello_web/controllers/user_session_* login (por link ou senha) e logout
lib/hello_web/controllers/user_registration_* cadastro
lib/hello_web/controllers/user_settings_* troca de e-mail e de senha
priv/repo/migrations/..._create_users_auth_tables.exs as tabelas users e users_tokens
test/... os testes de tudo isso
E altera estes:
mix.exs: acrescenta{:bcrypt_elixir, "~> 3.0"}, a biblioteca que calcula o hash das senhas;config/config.exs: registra o escopouser(a seção "Escopos", abaixo, mostra o bloco);lib/hello_web/router.ex: importa oHelloWeb.UserAuth, põe o plug:fetch_current_scope_for_userna pipeline:browsere acrescenta as rotas de autenticação;root.html.heex: o menu do topo, com o e-mail de quem está logado e os links de login e logout;config/test.exsetest/support/conn_case.ex: ajustes para os testes.
No fim, o gerador pede mix deps.get (para baixar o bcrypt_elixir) e mix ecto.migrate, e sugere criar uma conta em /users/register e ver o e-mail de confirmação em /dev/mailbox.
As rotas que ele acrescenta ao router ficam em três blocos, cada um com uma exigência diferente:
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
O cadastro passa por :redirect_if_user_is_authenticated: quem já está logado volta para a página inicial. As configurações passam por :require_authenticated_user: quem não está logado vai para a página de login. O guia mostra essas rotas como /user/register e /user/settings, no singular; o gerador do 1.8.16 usa /users/..., no plural.
Na trilha: uma autenticação reduzida
O phx.gen.auth completo depende do bcrypt_elixir, que tem código em C, e de e-mail: no 1.8, o login padrão é por um link enviado ao endereço do usuário. Nada disso cabe bem num notebook, e a autenticação completa é assunto de um módulo próprio da trilha, mais adiante. Aqui você usa uma versão reduzida, com os mesmos nomes de módulos e de funções:
No phx.gen.auth |
Nesta versão reduzida |
|---|---|
| Login por link enviado por e-mail, ou por e-mail e senha | Login só com o e-mail; um e-mail novo cria a conta na hora |
Tabela users com email (citext), hashed_password e confirmed_at |
Tabela users só com email |
Tabela users_tokens; a sessão guarda um token, que pode ser revogado |
Sem tokens; a sessão guarda o user_id |
| Cadastro, confirmação, troca de e-mail e de senha, "lembrar de mim", modo sudo | Só login e logout |
Accounts, User, Scope, UserAuth com fetch_current_scope_for_user, require_authenticated_user, redirect_if_user_is_authenticated, log_in_user, log_out_user |
Os mesmos módulos e funções, com o mesmo papel |
O que o guia usa está igual: o Scope, o assign current_scope e os plugs que protegem as rotas. Não leve esta versão para um projeto de verdade: nela, qualquer pessoa entra com qualquer e-mail, sem senha.
priv/repo/migrations/20250205200000_create_users_auth_tables.exs: só a tabela users, com um índice único no e-mail.
defmodule Hello.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
end
end
Trilha.migracao(20250205200000, Hello.Repo.Migrations.CreateUsersAuthTables)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/accounts/user.ex, lib/hello/accounts.ex e lib/hello/accounts/scope.ex. O Scope é o arquivo gerado, sem mudança nenhuma.
defmodule Hello.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
timestamps(type: :utc_datetime)
end
@doc "A user changeset for registering or changing the email."
def email_changeset(user, attrs) do
user
|> cast(attrs, [:email])
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unique_constraint(:email)
end
end
defmodule Hello.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.User
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
def get_user!(id), do: Repo.get!(User, id)
def get_user(id), do: Repo.get(User, id)
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
end
defmodule Hello.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `Hello.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use in authorization checks,
or to ensure specific code paths can only be accessed for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias Hello.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
lib/hello_web/user_auth.ex: os plugs. require_authenticated_user/2, redirect_if_user_is_authenticated/2 e maybe_store_return_to/1 são iguais aos gerados. fetch_current_scope_for_user/2, log_in_user/2 e log_out_user/1 fazem o mesmo trabalho com o user_id na sessão, em vez de um token.
defmodule HelloWeb.UserAuth do
use HelloWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Hello.Accounts
alias Hello.Accounts.Scope
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_user(conn, user) do
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_session(:user_id, user.id)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc "Logs the user out. It clears all session data for safety."
def log_out_user(conn) do
conn
|> renew_session()
|> redirect(to: ~p"/")
end
@doc "Authenticates the user by looking into the session."
def fetch_current_scope_for_user(conn, _opts) do
user = if user_id = get_session(conn, :user_id), do: Accounts.get_user(user_id)
assign(conn, :current_scope, Scope.for_user(user))
end
defp renew_session(conn) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Plug for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
defp signed_in_path(_conn), do: ~p"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
end
A célula avisa no route path for HelloWeb.Router matches "/users/log-in". É verdade, por enquanto: o router ainda é o do topo, sem as rotas de login. O ~p confere cada caminho quando o módulo compila, e o router novo, mais abaixo, precisa do UserAuth já compilado, porque o importa. No projeto, o compilador resolve essa dependência circular sozinho e o aviso não aparece. Aqui, se quiser vê-lo sumir, avalie esta célula de novo depois do router.
lib/hello_web/controllers/user_session_controller.ex: as actions de entrar e sair. O create busca o usuário pelo e-mail e, se ele não existe, o cadastra. Esta célula vem antes do router porque não usa ~p; assim o router já encontra o controller.
defmodule HelloWeb.UserSessionController do
use HelloWeb, :controller
alias Hello.Accounts
alias HelloWeb.UserAuth
def new(conn, _params) do
render(conn, :new, form: Phoenix.Component.to_form(%{}, as: "user"))
end
def create(conn, %{"user" => %{"email" => email}}) do
case Accounts.get_user_by_email(email) || Accounts.register_user(%{email: email}) do
{:error, changeset} ->
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
{:ok, user} ->
conn
|> put_flash(:info, "Account created. Welcome!")
|> UserAuth.log_in_user(user)
user ->
conn
|> put_flash(:info, "Welcome back!")
|> UserAuth.log_in_user(user)
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end
lib/hello_web/router.ex: o router do topo com as mudanças do phx.gen.auth. O import HelloWeb.UserAuth traz os plugs de função para dentro do router; a pipeline :browser ganha :fetch_current_scope_for_user; as rotas de login e logout entram num bloco próprio. Daqui em diante, cada mudança no router é uma célula nova, com o router inteiro, porque ele depende de módulos definidos mais abaixo.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
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
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
resources "/products", ProductController
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
lib/hello_web/controllers/user_session_html.ex: a página de login, com um formulário que só pede o e-mail.
defmodule HelloWeb.UserSessionHTML do
use HelloWeb, :html
# user_session_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<.header>
Log in
<:subtitle>Trilha version: just the email, no password. New emails create an account.</:subtitle>
</.header>
<.form :let={f} for={@form} action={~p"/users/log-in"}>
<.input field={f[:email]} type="email" label="Email" required />
<.button variant="primary" class="btn btn-primary w-full">Log in</.button>
</.form>
</div>
</Layouts.app>
"""
end
end
lib/hello_web/components/layouts.ex: o root layout ganha o menu que o phx.gen.auth injeta no root.html.heex. A versão gerada também tem os links "Settings" e "Register", que a versão reduzida não tem.
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>
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
{@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
A Bia abre a loja. Ninguém fez login ainda:
Trilha.navegar("bia", :get, "/")
Agora ela entra com o e-mail. O formulário de login envia user[email], que chega ao controller como %{"user" => %{"email" => ...}}:
Trilha.navegar("bia", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})
O POST respondeu 302 para /, com a flash "Account created. Welcome!", e a página seguinte já mostra quem está logado. Entre as duas requisições, quem guardou o login foi o cookie da sessão: log_in_user/2 pôs o user_id nela, e o navegador (aqui, o Trilha.navegar) mandou o cookie de volta. No navegador de verdade, faça o mesmo em http://localhost:4010/users/log-in; o menu no topo passa a mostrar o seu e-mail.
Escopos
Trilha.pergunta(:autenticacao_autorizacao)
Autenticação diz quem é o usuário. Autorização diz o que ele pode acessar. O phx.gen.auth do 1.8 resolve a primeira e deixa uma ferramenta para a segunda: o escopo, o módulo Hello.Accounts.Scope da célula acima. É uma struct com um campo, user, criada por Scope.for_user/1 (que devolve nil quando não há usuário). O plug fetch_current_scope_for_user a coloca em conn.assigns.current_scope a cada requisição:
{:ok, conn} = Trilha.visitar("bia", :get, "/")
conn.assigns.current_scope
Por que uma struct só para guardar o usuário? Porque ela pode crescer: uma organização, um papel de administrador, um time. Os contextos recebem o escopo inteiro, e não o usuário, então acrescentar um campo não muda a assinatura de nenhuma função.
O gerador também registrou o escopo em config/config.exs. Este é o bloco que o phx.gen.auth do 1.8.16 escreve no projeto:
config :hello, :scopes,
user: [
default: true,
module: Hello.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Hello.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
Esse bloco não muda nada quando a aplicação roda: quem o lê são os geradores. Ele diz que o escopo user é o padrão (default: true), que fica no assign current_scope, que o dono de um registro se encontra em scope.user.id (access_path) e que, nas tabelas, ele vira a coluna user_id, do tipo :id, apontando para users. As duas últimas chaves dizem aos testes gerados como criar um usuário e fazer login. A seção seguinte mostra o efeito.
Recursos com escopo
Na documentação: Generating scoped resources
Com a autenticação no lugar, o guia gera o contexto do carrinho:
mix phx.gen.context ShoppingCart Cart carts
Trilha.pergunta(:escopo_padrao)
A saída do gerador:
* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20250205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
priv/repo/migrations/20250205203128_create_carts.exs: a migration gerada, sem mudanças. O user_id veio do escopo, com on_delete: :delete_all: apagar um usuário apaga o carrinho dele.
defmodule Hello.Repo.Migrations.CreateCarts do
use Ecto.Migration
def change do
create table(:carts) do
add :user_id, references(:users, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:carts, [:user_id])
end
end
Trilha.migracao(20250205203128, Hello.Repo.Migrations.CreateCarts)
O schema gerado, lib/hello/shopping_cart/cart.ex, ficou assim (a linha em branco depois de schema "carts" do é do gerador, que não tinha campo nenhum para pôr ali):
defmodule Hello.ShoppingCart.Cart do
use Ecto.Schema
import Ecto.Changeset
schema "carts" do
field :user_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart, attrs, user_scope) do
cart
|> cast(attrs, [])
|> validate_required([])
|> put_change(:user_id, user_scope.user.id)
end
end
Repare no changeset/3. Ele recebe o escopo e põe o user_id com put_change/3. O dono do carrinho nunca vem dos parâmetros, que qualquer um pode forjar: vem de quem está logado.
O contexto gerado, lib/hello/shopping_cart.ex, segue o mesmo padrão em todas as funções. O primeiro argumento é sempre o escopo, e toda consulta filtra pelo dono:
def list_carts(%Scope{} = scope) do
Repo.all_by(Cart, user_id: scope.user.id)
end
def get_cart!(%Scope{} = scope, id) do
Repo.get_by!(Cart, id: id, user_id: scope.user.id)
end
def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
true = cart.user_id == scope.user.id
...
end
get_cart!/2 só encontra o carrinho se ele for do usuário do escopo. Com o id do carrinho de outra pessoa, a consulta não acha nada e levanta Ecto.NoResultsError. As funções que recebem um carrinho já carregado, como update_cart/3, começam conferindo o dono com true = .... A seção "Cada um com o seu carrinho", mais adiante, mostra as duas coisas acontecendo.
O contexto gerado ainda tem subscribe_carts/1 e uma função privada, broadcast_cart/2, que publicam e assinam avisos de mudança pelo Phoenix.PubSub, num tópico por usuário ("user:#{key}:carts"). create_cart, update_cart e delete_cart chamam broadcast_cart depois de gravar. Nenhuma página deste notebook escuta esses avisos; eles servem às LiveViews, que a trilha estuda mais adiante. É por eles que a célula de inicialização liga o Hello.PubSub.
Os testes gerados também usam o escopo: cada teste cria dois usuários, com user_scope_fixture/0, e confere que um não enxerga os carrinhos do outro.
Agora os itens:
mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer --no-scope
Trilha.pergunta(:no_scope)
O gerador avisa que o contexto já existe, pede confirmação e então cria o schema e a migration e injeta as funções novas no contexto:
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20250205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex
priv/repo/migrations/20250205213410_create_cart_items.exs: a migration como o gerador a escreveu. O exercício logo abaixo a ajusta.
defmodule Hello.Repo.Migrations.CreateCartItems do
use Ecto.Migration
def change do
create table(:cart_items) do
add :price_when_carted, :decimal
add :quantity, :integer
add :cart_id, references(:carts, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:cart_items, [:cart_id])
create index(:cart_items, [:product_id])
end
end
Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)
Sua vez: a migration dos itens
Antes de migrar, o guia faz quatro mudanças na célula acima. Faça-as, avalie a célula e só então rode o mix ecto.migrate, logo abaixo:
price_when_cartedpassa a:decimal, precision: 15, scale: 6, null: false, como o preço dos produtos: dinheiro com precisão fixa e sempre presente.cart_idpassa aon_delete: :delete_all: apagar um carrinho apaga os itens dele.product_idtambém passa aon_delete: :delete_all: apagar um produto do catálogo o tira de todos os carrinhos.- O índice de
:cart_idsai, e entracreate unique_index(:cart_items, [:cart_id, :product_id]): o mesmo produto não pode aparecer duas vezes no mesmo carrinho.
As regras de on_delete ficam no banco de propósito. Assim nem o ShoppingCart nem o Catalog precisam de código para limpar itens órfãos, e os dois contextos continuam sem saber um do outro. O índice de :cart_id some porque o índice único começa por cart_id: um índice de várias colunas também atende às buscas pela primeira delas, e um índice a mais só deixaria as escritas mais lentas. É o mesmo raciocínio da tabela product_categories, no notebook 09.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
O log mostra as duas tabelas e os índices:
[info] == Running 20250205203128 Hello.Repo.Migrations.CreateCarts.change/0 forward
[info] create table carts
[info] create index carts_user_id_index
[info] == Migrated 20250205203128 in 0.0s
[info] == Running 20250205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward
[info] create table cart_items
[info] create index cart_items_product_id_index
[info] create index cart_items_cart_id_product_id_index
[info] == Migrated 20250205213410 in 0.0s
\d cart_items mostra o resultado. Confira o tipo de price_when_carted, os índices e as regras ON DELETE CASCADE das chaves estrangeiras:
Trilha.descrever("cart_items")
Trilha.conferir(:migracao_itens)
Trilha.solucao(:migracao_itens)
Se você migrou antes de editar, a tabela ficou com a versão gerada, e o Ecto não roda de novo uma migration que já rodou. Num projeto, você desfaria com mix ecto.rollback. Aqui, Trilha.desfazer_tabela("cart_items", 20250205213410) apaga a tabela e o registro da migration; depois, avalie a célula da migration e a do mix ecto.migrate.
As seções seguintes dependem do índice único. Sem ele, colocar um produto no carrinho falha com there is no unique or exclusion constraint matching the ON CONFLICT specification.
Dados que cruzam contextos
Na documentação: Cross-context data
Até aqui, Catalog e ShoppingCart não se conhecem. Mas um item de carrinho só existe se houver um produto: o ShoppingCart depende dos dados do Catalog.
Trilha.pergunta(:join_ou_api)
O guia apresenta as duas saídas. A primeira é o Catalog oferecer funções para o ShoppingCart buscar os produtos de que precisa, e o ShoppingCart juntar os dados em Elixir. A segunda é deixar o banco fazer o join. As duas são válidas; a primeira isola mais e custa mais código e mais consultas. Quando a dependência de dados é forte, como aqui, o join serve bem a muitas aplicações, e é o caminho do guia.
lib/hello/shopping_cart/cart.ex: o field :user_id, :id gerado vira belongs_to :user, Hello.Accounts.User, que define o mesmo campo user_id e mais a associação user. has_many :items liga o carrinho aos itens.
defmodule Hello.ShoppingCart.Cart do
use Ecto.Schema
import Ecto.Changeset
schema "carts" do
belongs_to :user, Hello.Accounts.User
has_many :items, Hello.ShoppingCart.CartItem
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart, attrs, user_scope) do
cart
|> cast(attrs, [])
|> validate_required([])
|> put_change(:user_id, user_scope.user.id)
end
end
lib/hello/shopping_cart/cart_item.ex: os campos cart_id e product_id gerados viram belongs_to. O segundo é a primeira dependência entre contextos: um schema do ShoppingCart apontando para um schema do Catalog. O guia faz isso de propósito: é o mínimo que o carrinho precisa saber para referenciar um produto. O changeset/2 desta célula ainda é o gerado; o exercício abaixo o muda.
defmodule Hello.ShoppingCart.CartItem do
use Ecto.Schema
import Ecto.Changeset
schema "cart_items" do
field :price_when_carted, :decimal
field :quantity, :integer
belongs_to :cart, Hello.ShoppingCart.Cart
belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart_item, attrs) do
cart_item
|> cast(attrs, [:price_when_carted, :quantity])
|> validate_required([:price_when_carted, :quantity])
end
end
Trilha.pergunta(:price_cast)
Sua vez: o changeset do item
Na célula do CartItem, mude o changeset/2 como o guia:
- tire
:price_when_carteddocast/3e dovalidate_required/3; o preço é responsabilidade do código, não de quem envia o formulário; - acrescente
validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100): quantidades de 0 a 99. O zero é aceito de propósito; mais adiante, ele significa "tire este item do carrinho".
Avalie a célula e teste:
alias Hello.ShoppingCart.CartItem
for params <- [%{"quantity" => "2", "price_when_carted" => "0.01"}, %{"quantity" => "100"}] do
changeset = CartItem.changeset(%CartItem{}, params)
%{params: params, changes: changeset.changes, errors: changeset.errors}
end
Depois da mudança, o primeiro changeset tem só quantity: 2 em changes, sem o preço forjado, e o segundo tem o erro must be less than 100.
Trilha.conferir(:item_changeset)
Trilha.solucao(:item_changeset)
Funções do carrinho
Na documentação: Adding Cart functions
Os geradores são só o ponto de partida. O contexto precisa de funções com nomes que digam o que fazem: garantir que todo usuário tenha um carrinho, pôr e tirar itens, atualizar quantidades, somar preços.
lib/hello/shopping_cart.ex: o contexto gerado, com as funções que o guia acrescenta. O guia chega a elas aos poucos, em três passos; aqui elas já estão todas na célula, e o texto abaixo dela segue a ordem do guia. A exceção é update_cart/3, que ainda é a gerada: ela é o exercício de mais adiante.
defmodule Hello.ShoppingCart do
@moduledoc """
The ShoppingCart context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog
alias Hello.ShoppingCart.{Cart, CartItem}
alias Hello.Accounts.Scope
@doc """
Subscribes to scoped notifications about any cart changes.
The broadcasted messages match the pattern:
* {:created, %Cart{}}
* {:updated, %Cart{}}
* {:deleted, %Cart{}}
"""
def subscribe_carts(%Scope{} = scope) do
key = scope.user.id
Phoenix.PubSub.subscribe(Hello.PubSub, "user:#{key}:carts")
end
defp broadcast_cart(%Scope{} = scope, message) do
key = scope.user.id
Phoenix.PubSub.broadcast(Hello.PubSub, "user:#{key}:carts", message)
end
@doc """
Returns the list of carts.
## Examples
iex> list_carts(scope)
[%Cart{}, ...]
"""
def list_carts(%Scope{} = scope) do
Repo.all_by(Cart, user_id: scope.user.id)
end
@doc """
Gets a single cart.
Raises `Ecto.NoResultsError` if the Cart does not exist.
## Examples
iex> get_cart!(scope, 123)
%Cart{}
iex> get_cart!(scope, 456)
** (Ecto.NoResultsError)
"""
def get_cart!(%Scope{} = scope, id) do
Repo.get_by!(Cart, id: id, user_id: scope.user.id)
end
def get_cart(%Scope{} = scope) do
Repo.one(
from(c in Cart,
where: c.user_id == ^scope.user.id,
left_join: i in assoc(c, :items),
left_join: p in assoc(i, :product),
order_by: [asc: i.inserted_at],
preload: [items: {i, product: p}]
)
)
end
@doc """
Creates a cart.
## Examples
iex> create_cart(scope, %{field: value})
{:ok, %Cart{}}
iex> create_cart(scope, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_cart(%Scope{} = scope, attrs) do
with {:ok, cart = %Cart{}} <-
%Cart{}
|> Cart.changeset(attrs, scope)
|> Repo.insert() do
broadcast_cart(scope, {:created, cart})
{:ok, get_cart(scope)}
end
end
def add_item_to_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
true = cart.user_id == scope.user.id
product = Catalog.get_product!(product_id)
%CartItem{quantity: 1, price_when_carted: product.price}
|> CartItem.changeset(%{})
|> Ecto.Changeset.put_assoc(:cart, cart)
|> Ecto.Changeset.put_assoc(:product, product)
|> Repo.insert(
on_conflict: [inc: [quantity: 1]],
conflict_target: [:cart_id, :product_id]
)
end
def remove_item_from_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
true = cart.user_id == scope.user.id
{1, _} =
Repo.delete_all(
from(i in CartItem,
where: i.cart_id == ^cart.id,
where: i.product_id == ^product_id
)
)
{:ok, get_cart(scope)}
end
def total_item_price(%CartItem{} = item) do
Decimal.mult(item.product.price, item.quantity)
end
def total_cart_price(%Cart{} = cart) do
Enum.reduce(cart.items, 0, fn item, acc ->
item
|> total_item_price()
|> Decimal.add(acc)
end)
end
@doc """
Updates a cart.
## Examples
iex> update_cart(scope, cart, %{field: new_value})
{:ok, %Cart{}}
iex> update_cart(scope, cart, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
true = cart.user_id == scope.user.id
with {:ok, cart = %Cart{}} <-
cart
|> Cart.changeset(attrs, scope)
|> Repo.update() do
broadcast_cart(scope, {:updated, cart})
{:ok, cart}
end
end
@doc """
Deletes a cart.
## Examples
iex> delete_cart(scope, cart)
{:ok, %Cart{}}
iex> delete_cart(scope, cart)
{:error, %Ecto.Changeset{}}
"""
def delete_cart(%Scope{} = scope, %Cart{} = cart) do
true = cart.user_id == scope.user.id
with {:ok, cart = %Cart{}} <-
Repo.delete(cart) do
broadcast_cart(scope, {:deleted, cart})
{:ok, cart}
end
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking cart changes.
## Examples
iex> change_cart(scope, cart)
%Ecto.Changeset{data: %Cart{}}
"""
def change_cart(%Scope{} = scope, %Cart{} = cart, attrs \\ %{}) do
true = cart.user_id == scope.user.id
Cart.changeset(cart, attrs, scope)
end
@doc """
Returns the list of cart_items.
## Examples
iex> list_cart_items()
[%CartItem{}, ...]
"""
def list_cart_items do
Repo.all(CartItem)
end
@doc """
Gets a single cart_item.
Raises `Ecto.NoResultsError` if the Cart item does not exist.
## Examples
iex> get_cart_item!(123)
%CartItem{}
iex> get_cart_item!(456)
** (Ecto.NoResultsError)
"""
def get_cart_item!(id), do: Repo.get!(CartItem, id)
@doc """
Creates a cart_item.
## Examples
iex> create_cart_item(%{field: value})
{:ok, %CartItem{}}
iex> create_cart_item(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_cart_item(attrs) do
%CartItem{}
|> CartItem.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a cart_item.
## Examples
iex> update_cart_item(cart_item, %{field: new_value})
{:ok, %CartItem{}}
iex> update_cart_item(cart_item, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_cart_item(%CartItem{} = cart_item, attrs) do
cart_item
|> CartItem.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a cart_item.
## Examples
iex> delete_cart_item(cart_item)
{:ok, %CartItem{}}
iex> delete_cart_item(cart_item)
{:error, %Ecto.Changeset{}}
"""
def delete_cart_item(%CartItem{} = cart_item) do
Repo.delete(cart_item)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking cart_item changes.
## Examples
iex> change_cart_item(cart_item)
%Ecto.Changeset{data: %CartItem{}}
"""
def change_cart_item(%CartItem{} = cart_item, attrs \\ %{}) do
CartItem.changeset(cart_item, attrs)
end
end
As funções novas, na ordem do guia:
get_cart/1busca o carrinho do usuário do escopo numa consulta só: doisleft_join(o carrinho com os itens, os itens com os produtos) e umpreloadque usa as linhas desses joins para montarcart.itemseitem.product. É o join entre contextos que o guia escolheu. Oleft_joinmantém o carrinho mesmo sem nenhum item;order_bydeixa os itens na ordem em que entraram.create_cart/2é a gerada com uma mudança: devolveget_cart(scope)em vez do carrinho recém-inserido, para que quem chamou já recebaitems: []carregado.add_item_to_cart/3confere o dono, busca o produto pelo contextoCatalog(um contexto pode chamar outro) e insere um item com quantidade 1 e o preço do momento.remove_item_from_cart/3apaga, comRepo.delete_all/1, o item daquele produto naquele carrinho.
Uma diferença em relação ao guia: no trecho de create_cart/2, o guia chama broadcast(scope, {:created, cart}), mas a função privada que o gerador do 1.8.16 cria se chama broadcast_cart/2. Copiado ao pé da letra, o trecho não compila (undefined function broadcast/2). A célula acima usa o nome gerado, como o próprio guia faz mais adiante, em update_cart/3.
Olhe de novo o Repo.insert de add_item_to_cart/3, com as opções on_conflict e conflict_target.
Trilha.pergunta(:upsert)
Esse insert é um upsert (insert ou update). Se o produto ainda não está no carrinho, entra uma linha nova. Se já está, o índice único [cart_id, product_id] da migration recusa a linha repetida, e on_conflict: [inc: [quantity: 1]] transforma a recusa num incremento. Como tudo acontece num comando só, dois cliques simultâneos não perdem uma unidade, pelo mesmo motivo do inc_page_views/1 do notebook 08.
Antes dos controllers, o router. O guia acrescenta à pipeline :browser um plug que garante um carrinho a todo usuário logado, e cria as rotas do carrinho num bloco que exige login.
lib/hello_web/router.ex: o router da seção de autenticação, com o plug :fetch_current_cart e o bloco do carrinho.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
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
plug :fetch_current_scope_for_user
plug :fetch_current_cart
end
alias Hello.ShoppingCart
defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
if cart = ShoppingCart.get_cart(scope) do
assign(conn, :cart, cart)
else
{:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
assign(conn, :cart, new_cart)
end
end
defp fetch_current_cart(conn, _opts), do: conn
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
resources "/products", ProductController
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
fetch_current_cart/2 tem duas cláusulas. A primeira só casa quando há alguém logado (current_scope diferente de nil): busca o carrinho e, se não houver, cria um. A segunda deixa o conn como está, para quem não fez login. Um plug de função definido no próprio router funciona como os plugs do UserAuth: recebe o conn e as opções e devolve o conn.
O trecho de router do guia mostra a pipeline :browser de versões antigas do Phoenix, com plug :put_root_layout, html: {HelloWeb.LayoutView, :root}. Num projeto 1.8 a linha é {HelloWeb.Layouts, :root}, como acima.
As rotas novas usam o bloco com :require_authenticated_user: resources com only: [:create, :delete] cria POST /cart_items e DELETE /cart_items/:id; GET /cart mostra o carrinho; PUT /cart recebe o formulário de quantidades. A célula avisa que HelloWeb.CartItemController.init/1 e HelloWeb.CartController.init/1 não existem: o router aponta para controllers que você ainda vai definir.
lib/hello_web/controllers/cart_item_controller.ex: pôr e tirar itens. As duas actions só repassam o pedido ao contexto, com o escopo e o carrinho que os plugs deixaram em conn.assigns. Os nomes add_item_to_cart e remove_item_from_cart dizem o que acontece sem que o controller saiba como.
defmodule HelloWeb.CartItemController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def create(conn, %{"product_id" => product_id}) do
case ShoppingCart.add_item_to_cart(conn.assigns.current_scope, conn.assigns.cart, product_id) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: ~p"/cart")
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.current_scope, conn.assigns.cart, product_id)
redirect(conn, to: ~p"/cart")
end
end
Sua vez: o botão "Add to cart"
A página do produto precisa de um botão que envie POST /cart_items?product_id=…. A célula abaixo é o ProductHTML do topo, que agora ganha uma versão nova. A do topo fica como estava, porque compila antes de existir a rota /cart_items.
Em show/1, dentro do slot :actions do <.header>, depois do botão "Edit product", acrescente:
<.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
Add to cart
</.button>
Com href, o <.button> dos CoreComponents vira um <.link>. Com method="post", o link ganha os atributos data-method="post" e data-to, e o phoenix_html.js (servido pelo endpoint) transforma o clique num formulário enviado por POST, com o token CSRF. Sem method, um link sempre faz GET.
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
# Sua vez: o botão "Add to cart" entra em show/1, no slot :actions do <.header>.
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_change(:categories, [])
|> Enum.map(& &1.data.id)
for cat <- Hello.Catalog.list_categories() do
[key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
end
# 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>
<:item title="Categories">
<ul>
<li :for={cat <- @product.categories}>{cat.title}</li>
</ul>
</: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" />
<.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
end
end
Veja a tag que o template gerou:
{:ok, conn} = Trilha.visitar("bia", :get, "/products/1")
Regex.run(~r{<a\b[^>]*>\s*Add to cart}, conn.resp_body)
Trilha.conferir(:botao)
Trilha.solucao(:botao)
Erro como conteúdo: o controller que ainda não existe
Primeiro, alguém sem login tenta pôr um produto no carrinho:
Trilha.navegar("visitante", :post, "/cart_items?product_id=1")
O plug :require_authenticated_user parou a requisição antes do controller (o log diz Phoenix.Router halted in :require_authenticated_user/2) e redirecionou para /users/log-in com a flash "You must log in to access this page." Num GET, ele também guardaria o caminho na sessão (:user_return_to), para voltar a ele depois do login; num POST, não guarda.
Agora a Bia, que está logada, faz o mesmo:
Trilha.navegar("bia", :post, "/cart_items?product_id=1")
Como no guia, "está funcionando, mais ou menos". Leia o log da célula de cima para baixo: o POST /cart_items passou pelas pipelines [:browser, :require_authenticated_user], o CartItemController.create/2 inseriu o item (o SQL termina em ON CONFLICT ("cart_id","product_id") DO UPDATE SET "quantity" = c0."quantity" + $7; o begin e o commit em volta aparecem porque o changeset tem associações, e o Ecto põe o insert numa transação) e redirecionou para /cart. No GET /cart, o carrinho foi buscado, e então veio a exceção: function HelloWeb.CartController.init/1 is undefined (module HelloWeb.CartController is not available). O router despachou para um controller que não existe.
O item foi gravado:
Trilha.consultar("SELECT * FROM cart_items")
lib/hello_web/controllers/cart_controller.ex: a página do carrinho. O template vai precisar de um changeset do carrinho, para o formulário de quantidades; ShoppingCart.change_cart/3, gerada, devolve um. O carrinho já está em conn.assigns.cart, posto pelo plug do router.
defmodule HelloWeb.CartController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def show(conn, _params) do
render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
end
end
lib/hello_web/controllers/cart_html.ex e o template cart_html/show.html.heex
defmodule HelloWeb.CartHTML do
use HelloWeb, :html
alias Hello.ShoppingCart
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
# cart_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
My Cart
<:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
</.header>
<div :if={@cart.items !== []}>
<.form :let={f} for={@changeset} action={~p"/cart"}>
<.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
<.input field={item_form[:quantity]} type="number" label={item.product.title} />
{currency_to_str(ShoppingCart.total_item_price(item))}
</.inputs_for>
<.button>Update cart</.button>
</.form>
<b>Total</b>: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
</div>
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>
"""
end
end
O template:
- mostra "Your cart is empty" quando
@cart.itemsé vazio.@cartnão foi passado pelorender/3: os assigns doconn, comocartecurrent_scope, chegam ao template junto com os dorender; - monta um formulário a partir do changeset, com
<.inputs_for>para os itens: um campo de quantidade por item, com o título do produto como rótulo.%{data: item}tira do formulário de cada item oCartItemoriginal, com o produto carregado; - mostra o total de cada item e o do carrinho com
total_item_price/1etotal_cart_price/1. O guia as escreve neste ponto; aqui elas já estavam na célula doShoppingCart. As duas usamDecimal.mult/2eDecimal.add/2: dinheiro não se calcula comfloat; - formata os valores com
currency_to_str/1, que arredonda para duas casas e põe o cifrão.
Trilha.navegar("bia", :get, "/cart")
A Bia clica de novo em "Add to cart" no mesmo produto. É a pergunta do upsert:
Trilha.navegar("bia", :post, "/cart_items?product_id=1")
A quantidade passou a 2, e a tabela continua com uma linha só:
Trilha.consultar("SELECT id, cart_id, product_id, quantity, price_when_carted FROM cart_items ORDER BY id")
Ela põe também o outro produto:
Trilha.navegar("bia", :post, "/cart_items?product_id=2")
Trilha.consultar("SELECT id, cart_id, product_id, quantity, price_when_carted FROM cart_items ORDER BY id")
Seguindo a ordem do notebook, o item novo ficou com id 3, e não 2. O PostgreSQL tira o próximo número da sequência antes de tentar o insert; quando o upsert vira update, o número reservado não é devolvido. Buracos na sequência de ids são normais e não indicam problema.
Erro como conteúdo: a action que falta
O carrinho tem um formulário de quantidades. Veja o que ele enviaria:
Trilha.formulario("bia", "/cart")
Quatro detalhes:
- O formulário é
POST /cart, mas leva o campo escondido_methodcom o valorput. O<.form>faz isso sozinho quando o changeset é de um registro que já existe no banco. Navegadores só enviam formulários comGETePOST; oPlug.MethodOverride, no endpoint, troca o método paraPUTantes de a requisição chegar ao router. - Para cada item,
<.inputs_for>gerou campos escondidos: oid(cart[items][0][id]), que mais adiante permite ao Ecto saber qual item cada quantidade atualiza, e_persistent_id, que o LiveView usa para acompanhar itens de formulários dinâmicos e que o Ecto ignora. - Os itens aparecem na ordem em que entraram no carrinho (
order_by: [asc: i.inserted_at]), einserted_attem precisão de segundos. Dois itens que entraram no mesmo segundo podem trocar de posição entre uma página e outra. Por isso as células abaixo preenchem os campos pelo rótulo que aparece na tela, como você faria olhando a página, e não pela posição. - Não há campo de preço. Mesmo assim, quem quisesse poderia mandar um; é o que a pergunta sobre o
castdiscutiu.
A Bia muda a quantidade de "Metaprogramming Elixir" para 3 e clica em "Update cart":
Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3"})
A rota existe, mas o controller não tem a action: function HelloWeb.CartController.update/2 is undefined or private. No guia, o log mostra POST /cart, que é o que o navegador envia; o router recebe o PUT que o Plug.MethodOverride produziu.
lib/hello_web/controllers/cart_controller.ex: agora com update/2. Se o contexto atualizar, volta para o carrinho; se não, volta com uma flash de erro.
defmodule HelloWeb.CartController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def show(conn, _params) do
render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
end
def update(conn, %{"cart" => cart_params}) do
case ShoppingCart.update_cart(conn.assigns.current_scope, conn.assigns.cart, cart_params) do
{:ok, _cart} ->
redirect(conn, to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error updating your cart")
|> redirect(to: ~p"/cart")
end
end
end
Envie de novo:
Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3"})
Sem erro, mas a quantidade continua 2. O update_cart/3 gerado só faz cast dos campos do próprio Cart, que são nenhum (cast(attrs, [])); os dados dos itens chegam e são ignorados.
Sua vez: update_cart/3
O guia troca o update_cart/3 gerado por uma versão que atualiza os itens. Ela tem três partes:
- O changeset. Comece como a gerada, com
Cart.changeset(attrs, scope), e encadeieEcto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2).cast_assoc/3aplica os parâmetros de cada item aoCartItemcorrespondente, peloidescondido do formulário. Os itens precisam estar carregados no carrinho, e estão, porque o plug usouget_cart/1. Cada item passa pelochangeset/2do exercício anterior, então uma quantidade 100 invalida o carrinho inteiro. - A transação. Dentro de
Repo.transact(fn -> ... end), rodeRepo.update(changeset)e, se der certo, umRepo.delete_all/1dos itens deste carrinho comquantity == 0. A função passada aRepo.transact/2devolve{:ok, valor}, e a transação é confirmada, ou{:error, motivo}, e tudo é desfeito. Umwithresolve: o{:error, changeset}doRepo.updatesai dele direto e desfaz a transação. - O aviso. Depois da transação, se ela deu certo, chame
broadcast_cart(scope, {:updated, cart})e devolva{:ok, cart}; se não, devolva o erro como veio.
Mantenha a primeira linha, true = cart.user_id == scope.user.id.
Na célula do ShoppingCart, troque o update_cart/3 gerado pela sua versão e avalie a célula. O CartController não precisa mudar.
Depois, a Bia põe 3 unidades de "Metaprogramming Elixir" e zera "Elixir Flashcards":
Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "3", "Elixir Flashcards" => "0"})
O carrinho fica só com "Metaprogramming Elixir", 3 unidades, $45.00. Agora uma quantidade fora do limite:
Trilha.enviar("bia", "/cart", %{"Metaprogramming Elixir" => "100"})
O log mostra a transação aberta e desfeita (begin e rollback), a flash "There was an error updating your cart" aparece, e o carrinho continua como estava.
Trilha.conferir(:update_cart)
Trilha.solucao(:update_cart)
Cada um com o seu carrinho
O Caio entra na loja:
Trilha.navegar("caio", :post, "/users/log-in", %{"user" => %{"email" => "caio@example.com"}})
Trilha.navegar("caio", :get, "/cart")
O carrinho dele está vazio. O plug criou um carrinho novo no primeiro acesso do Caio, e get_cart/1 só busca o carrinho do usuário do escopo. Na tabela, os números pulados (se houver) são de carrinhos que as conferências dos exercícios criaram e apagaram:
Trilha.consultar("SELECT id, user_id, inserted_at FROM carts ORDER BY id")
E se algum código errado misturar os dois? Pegue os escopos e o carrinho da Bia:
escopo_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))
escopo_do_caio = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("caio@example.com"))
carrinho_da_bia = Hello.ShoppingCart.get_cart(escopo_da_bia)
%{dono_do_carrinho: carrinho_da_bia.user_id, caio: escopo_do_caio.user.id}
Trilha.pergunta(:true_match)
Hello.ShoppingCart.update_cart(escopo_do_caio, carrinho_da_bia, %{})
MatchError com o valor false: a primeira linha de update_cart/3 não deixou o carrinho da Bia ser alterado com o escopo do Caio. E pelo id, com a função que busca filtrando pelo escopo:
Hello.ShoppingCart.get_cart!(escopo_do_caio, carrinho_da_bia.id)
Ecto.NoResultsError: o where da consulta pede o id do carrinho da Bia e o user_id do Caio, e nenhuma linha tem os dois. Numa requisição, o phoenix_ecto faz essa exceção virar uma resposta 404, como se o carrinho não existisse. As duas células acima falham de propósito.
O preço de quando o item entrou
Trilha.pergunta(:preco_atual)
A loja aumenta o preço do produto 1. (Sem category_ids nos parâmetros, o change_product/2 do notebook 09 deixa o produto sem categorias; o produto 1 não tem nenhuma, então nada se perde.)
{:ok, produto} = Hello.Catalog.update_product(Hello.Catalog.get_product!(1), %{"price" => "20"})
produto.price
Trilha.navegar("bia", :get, "/cart")
O carrinho da Bia passou a $60.00 sem que ela fizesse nada. A tabela cart_items ainda tem 15.000000 em price_when_carted, mas ninguém a usa.
Sua vez: avisar que o preço mudou
Na célula do CartHTML, faça a página avisar quando o preço de um item mudou desde que ele entrou no carrinho: ao lado do total do item, mostre (was $15.00), com o preço guardado em price_when_carted, só quando ele for diferente do preço atual (item.product.price). Compare os dois com Decimal.equal?/2, e não com ==: 15.000000 e 15 são o mesmo valor, mas structs Decimal diferentes.
Trilha.navegar("bia", :get, "/cart")
Trilha.conferir(:preco_mudou)
Trilha.solucao(:preco_mudou)
Uma loja real decidiria o que fazer com a diferença: cobrar o preço antigo, o novo, ou pedir confirmação. O ponto do guia é que, sem guardar o preço do momento, nem daria para perceber. Volte o preço ao original:
{:ok, produto} = Hello.Catalog.update_product(Hello.Catalog.get_product!(1), %{"price" => "15"})
produto.price
Tirando um item
A última action do CartItemController é delete. Na rota DELETE /cart_items/:id, o :id é o id do produto, não o do item: remove_item_from_cart/3 recebe o carrinho e o produto. Um link com method="delete" faria esta requisição no navegador.
Trilha.navegar("bia", :delete, "/cart_items/1")
O carrinho ficou vazio. remove_item_from_cart/3 exige que exatamente uma linha seja apagada ({1, _} = ...); pedir para tirar um produto que não está no carrinho é tratado como bug e levanta MatchError.
Sem roteiro: a lista de desejos do Brechó
Agora o mesmo assunto sem instruções passo a passo, no Brechó, o mini-marketplace da trilha.
O Brechó até aqui
As células abaixo trazem o Brechó como o desafio do notebook 08 o deixou: a tabela produtos, o contexto Hello.Vitrine e a página /produtos. Avalie-as em ordem; o router é o do carrinho, com as rotas do Brechó acrescentadas.
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/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
lib/hello_web/router.ex: o router do carrinho, com as rotas do Brechó. As rotas da lista de desejos entram no bloco marcado.
defmodule HelloWeb.Router do
use HelloWeb, :router
import HelloWeb.UserAuth
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
plug :fetch_current_scope_for_user
plug :fetch_current_cart
end
alias Hello.ShoppingCart
defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
if cart = ShoppingCart.get_cart(scope) do
assign(conn, :cart, cart)
else
{:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
assign(conn, :cart, new_cart)
end
end
defp fetch_current_cart(conn, _opts), do: conn
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
post "/produtos/:id/vender", ProdutoController, :vender
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
# Sem roteiro: as rotas da lista de desejos entram aqui.
end
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
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
Três peças na vitrine:
for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}, {"Bota de couro", 12000}] do
{:ok, _} = Hello.Vitrine.cadastrar_produto(%{nome: nome, preco_centavos: preco})
end
Trilha.navegar("bia", :get, "/produtos")
O desafio
No Brechó, quem está logado pode guardar peças numa lista de desejos, para decidir depois. Cada lista é de um usuário, como o carrinho. Faça:
- Uma migration com a versão
20250322000000, que crie a tabeladesejoscomuser_id(referência ausers) eproduto_id(referência aprodutos), as duas comon_delete: :delete_all, os timestamps e um índice único de[:user_id, :produto_id]. Rode omix ecto.migrate. - Um contexto
Hello.Desejos, com o schemaHello.Desejos.Desejo(belongs_to :userebelongs_to :produto, Hello.Vitrine.Produto) e três funções que recebem o escopo, como as geradas:desejar(scope, produto_id)guarda a peça na lista; guardar a mesma peça de novo não duplica a linha nem dá erro, e devolve{:ok, _}nos dois casos;listar(scope)devolve os desejos do usuário, cada um com o produto carregado emdesejo.produto;remover(scope, produto_id)tira a peça da lista do usuário.
- Duas rotas, só para quem fez login:
POST /desejos?produto_id=…guarda a peça e redireciona paraGET /desejos, uma página que lista o nome das peças guardadas.
Pense em quem é o dono de cada linha e em onde esse dono é conferido. O desafio usa as peças do Brechó, mas a pergunta da seção "Dados que cruzam contextos" vale aqui também: Desejos depende de Vitrine e de Accounts.
Use as células abaixo e edite a do router, acima.
# priv/repo/migrations/20250322000000_create_desejos.exs
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/desejos/desejo.ex e lib/hello/desejos.ex
# lib/hello_web/controllers/desejo_controller.ex e desejo_html.ex
Para testar como a Bia (antes de resolver, a resposta é um erro de rota):
Trilha.navegar("bia", :post, "/desejos?produto_id=1")
A célula do router avisa que HelloWeb.ProdutoController.init/1 não existe: esse controller vem na célula logo depois dela. Depois que você acrescentar as rotas dos desejos, o aviso também cita HelloWeb.DesejoController, até a célula do controller ser avaliada; avalie o router de novo depois dela, e os avisos somem.
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Repita o guia num projeto gerado por mix phx.new hello, no estado em que o notebook 09 o deixou. A correspondência entre as células e os arquivos e comandos é esta:
| Neste notebook | No projeto |
|---|---|
Autenticação reduzida (Accounts, User, Scope, UserAuth, UserSession*) |
mix phx.gen.auth Accounts User users (responda n para usar controllers), mix deps.get e mix ecto.migrate |
Login com Trilha.navegar(quem, :post, "/users/log-in", ...) |
Cadastro em /users/register, e-mail de confirmação em /dev/mailbox |
Células das migrations + Trilha.migracao/2 |
Arquivos que mix phx.gen.context cria em priv/repo/migrations/ |
Células do ShoppingCart, Cart e CartItem |
lib/hello/shopping_cart.ex e lib/hello/shopping_cart/ |
| Uma célula nova do router a cada mudança | lib/hello_web/router.ex, editado no lugar |
Trilha.desfazer_tabela/2 |
mix ecto.rollback |
Trilha.navegar, Trilha.formulario, Trilha.enviar |
O navegador, em http://localhost:4000 |
Roteiro:
- Rode
mix phx.gen.auth Accounts User users, respondan, rodemix deps.getemix ecto.migrate. A migration gerada cria a extensãocitextdo PostgreSQL, para comparar e-mails sem diferenciar maiúsculas. No Fedora, ela vem num pacote à parte; sem ele, omix ecto.migratefalha comextension "citext" is not available. Instale comsudo dnf install postgresql-contribe rode omix ecto.migratede novo. Depois, leia o blococonfig :hello, :scopesemconfig/config.exse olib/hello/accounts/scope.ex. Crie uma conta em/users/registere confirme pelo/dev/mailbox. - Rode
mix phx.gen.context ShoppingCart Cart cartse leia o contexto gerado: todas as funções recebem o escopo. Leia tambémtest/hello/shopping_cart_test.exs, que testa um usuário contra outro. - Rode
mix phx.gen.context ShoppingCart CartItem cart_items cart_id:references:carts product_id:references:products price_when_carted:decimal quantity:integer --no-scope, ajuste a migration como no exercício e rodemix ecto.migrate. - Faça as mudanças do guia nos schemas, no contexto, no router, nos controllers e nos templates. No
create_cart/2, usebroadcast_cart/2, o nome que o gerador criou. - Ponha produtos no carrinho pelo navegador, mude quantidades, zere uma delas. Abra uma janela anônima, crie outra conta e confira que os carrinhos são separados.
- Rode
mix test test/hello/shopping_cart_test.exs. Logo depois dos geradores, 2 dos 18 testes já falham: os de "invalid data" decreate_cart/2eupdate_cart/3esperam um erro, mas umCartsem campos não tem o que invalidar. Depois das mudanças do guia, falham 11: as fixtures de itens mandamprice_when_carted, que saiu docast(e a coluna não aceitaNULL), ecreate_cart/2passou a devolver o carrinho com os itens carregados, diferente do quelist_carts/1eget_cart!/2devolvem. Ajustar esses testes é um bom exercício: leia cada falha e decida se o teste ou o código está errado.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que é um escopo, por que o Cart tem escopo e o CartItem não, e o que acontece entre o clique em "Add to cart" e a linha nova em cart_items. Depois compare com os pontos abaixo.
- Autenticação diz quem é o usuário; autorização diz o que ele pode acessar. O
phx.gen.authcuida da primeira e deixa o escopo para a segunda. - O escopo (
%Scope{user: user}) chega aos controllers emconn.assigns.current_scope, pelo plugfetch_current_scope_for_user. Os contextos o recebem como primeiro argumento e filtram cada consulta pelo dono. - Com um escopo padrão em
config :hello, :scopes, os geradores criamuser_id,changeset/3comput_change(:user_id, ...), consultas filtradas e asserçõestrue = recurso.user_id == scope.user.id.--no-scopedesliga isso para recursos que chegam ao dono por outro caminho, como os itens pelo carrinho. - Um contexto pode depender dos dados de outro (
belongs_to :product, Hello.Catalog.Product, joins) e chamar as funções de outro (Catalog.get_product!/1) sem conhecer as regras dele. - Restrições do banco fazem parte do desenho:
on_delete: :delete_alllimpa itens órfãos sem código nos contextos, e o índice único permite o upsert (on_conflict+conflict_target), atômico. - O preço do momento (
price_when_carted) nunca vem da entrada do usuário: sai docaste é posto pelo código. cast_assoc/3atualiza os itens a partir do formulário, guiado peloidescondido que<.inputs_for>gera.Repo.transact/2agrupa passos que precisam acontecer juntos.- Nomes de funções que dizem a intenção (
add_item_to_cart,remove_item_from_cart) deixam os controllers curtos e permitem escrever a camada web antes dos detalhes do contexto.
Próximo guia da trilha: Bringing It Home, que fecha o pedido num terceiro contexto, Orders, a partir do carrinho deste notebook.