Trilha Phoenix · Fechando o pedido
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
o psql mostraria, registra migrations, faz requisições ao endpoint como um
navegador faria, confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
esquecer_visitantes()
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@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
@doc "O id do pedido mais recente do usuário com este e-mail."
def ultimo_pedido(email) do
case sql("SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.email = $1 ORDER BY o.id DESC LIMIT 1", [email]).rows do
[[id]] -> id
[] -> raise "#{email} ainda não tem nenhum pedido. Faça os exercícios das seções anteriores e complete um pedido."
end
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "bia"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
end
defp resumo_final({_requisicao, {:erro, erro}}) do
"**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
end
@doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
def texto(html) do
conteudo =
case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
[_, dentro] -> dentro
nil -> html
end
conteudo
|> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
|> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
|> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
|> String.replace(~r{<option[^>]*>}, "[ ] ")
|> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
|> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
|> String.replace(~r{</t[dh]>}, " ")
|> String.replace(~r{<[^>]+>}, "")
|> String.replace(["&", "<", ">", """, "'", " "], fn
"&" -> "&"
"<" -> "<"
">" -> ">"
""" -> "\""
"'" -> "'"
" " -> " "
end)
|> String.split("\n")
|> Enum.map(&(&1 |> String.replace(~r/[ \t]+/, " ") |> String.trim()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- 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_mora: %{
texto:
"a Bia vai fechar a compra. O pedido precisa guardar o preço de cada item no momento do pagamento, e as regras são outras: um item sem estoque pode ficar no carrinho, mas não pode ser comprado. Onde deve morar o código que completa um pedido?",
opcoes: [
a: "No `Catalog`, porque o pedido é feito de produtos.",
b: "No `ShoppingCart`, porque o pedido nasce do carrinho.",
c: "Em um contexto novo, `Orders`, com dados e regras próprios.",
d: "No `OrderController`, porque completar o pedido é uma ação da web."
],
correta: :c,
explicacao:
"O pedido usa dados do catálogo e do carrinho, mas tem dados próprios (o preço cobrado, a quantidade comprada) e regras próprias (não se completa um pedido sem estoque). Por isso ganha um contexto, `Orders`. O controller só traduz a requisição em uma chamada ao contexto, como em todo o módulo."
},
escopo_padrao: %{
texto:
"o comando `mix phx.gen.context Orders Order orders total_price:decimal` não fala em usuário. Mesmo assim, a migration gerada tem `user_id`, e todas as funções do contexto recebem um `scope`. Como o gerador soube?",
opcoes: [
a: "Ele procura uma tabela `users` no banco.",
b:
"O `phx.gen.auth` registrou em `config/config.exs` um escopo `user` com `default: true`, e os geradores usam o escopo padrão quando não recebem `--scope` nem `--no-scope`.",
c: "Todo contexto gerado no Phoenix 1.8 tem `user_id`, com ou sem autenticação.",
d: "O nome `Orders` está numa lista de recursos que pertencem a usuários."
],
correta: :b,
explicacao:
"A configuração `config :hello, :scopes` diz qual módulo representa o escopo (`Hello.Accounts.Scope`), em qual assign do conn ele chega (`:current_scope`), qual campo vai no schema (`user_id`) e como chegar ao valor (`[:user, :id]`). Com `default: true`, ela vale para todo gerador. `--no-scope` desliga, como no `LineItem` logo abaixo, e `--scope NOME` escolhe outro escopo, quando a aplicação tem mais de um."
},
has_many_through: %{
texto: "o `Order` vai declarar `has_many :products, through: [:line_items, :product]`. O que essa linha cria no banco?",
opcoes: [
a: "Uma tabela `order_products`, ligando pedidos e produtos.",
b: "Uma coluna `product_ids` na tabela `orders`.",
c: "Nada: é um caminho pelas associações que já existem, os `line_items` do pedido e o `product` de cada item.",
d: "Um índice em `order_line_items.product_id`."
],
correta: :c,
explicacao:
"Associações declaradas no schema são do Ecto, não do banco. `Repo.preload(order, :products)` percorre `order.line_items` e depois o `product` de cada item, usando as tabelas `order_line_items` e `products`, que já existem. Nenhuma migration é necessária."
},
nome_da_funcao: %{
texto:
"o contexto gerado já tem `Orders.create_order(scope, attrs)`. O guia mesmo assim escreve uma função nova, `Orders.complete_order(scope, cart)`. Por quê?",
opcoes: [
a: "Porque `create_order/2` não funciona com escopo.",
b:
"Porque completar um pedido é mais que inserir uma linha: cria os itens com o preço do momento, esvazia o carrinho e, um dia, cobra o pagamento. O nome diz a intenção.",
c: "Porque um controller não pode chamar funções geradas.",
d: "Por gosto; as duas fazem a mesma coisa."
],
correta: :b,
explicacao:
"`create_order/2` só faz um insert a partir de parâmetros. Completar o pedido é uma operação de negócio com várias etapas, e é a mais importante do sistema: é onde o dinheiro muda de mãos. Um nome como `complete_order` deixa isso claro para quem lê o controller, sem precisar abrir o contexto."
},
transacao: %{
texto:
"dentro de `complete_order/2`, o pedido é inserido e, em seguida, `prune_cart_items/2` esvazia o carrinho. Se a segunda etapa devolver um erro, o que fica no banco?",
opcoes: [
a: "O pedido, e o carrinho continua cheio: o usuário pode comprar tudo de novo.",
b: "Nada muda: as duas etapas estão no mesmo `Repo.transact/2`, e o erro desfaz o insert do pedido.",
c: "O pedido, com `total_price` zerado.",
d: "O Ecto tenta a segunda etapa de novo até dar certo."
],
correta: :b,
explicacao:
"`Repo.transact/2` abre uma transação no banco e executa a função. Se ela devolve `{:ok, valor}`, a transação é confirmada (commit). Se devolve `{:error, motivo}`, tudo o que foi feito dentro dela é desfeito (rollback), e `transact` devolve o mesmo `{:error, motivo}`."
},
preco_no_pedido: %{
texto: "o pedido da Bia foi fechado com o livro a $15. Depois, a loja sobe o preço do livro para $20. O que a página do pedido mostra para esse item?",
opcoes: [
a: "$20, porque o item do pedido aponta para o produto.",
b: "$15: o preço foi copiado para `order_line_items.price` quando o pedido foi completado.",
c: "Os dois preços, o antigo riscado.",
d: "Um erro, porque o preço mudou."
],
correta: :b,
explicacao:
"O item do pedido aponta para o produto (`product_id`), mas também guarda uma cópia do preço no momento da compra. A página usa a cópia, `item.price`. É isso que o guia chama de informação do momento (*point-in-time*): o que foi cobrado não pode mudar quando o catálogo muda."
},
pedido_alheio: %{
texto:
"o Caio está logado e abre o endereço do último pedido da Bia. A action `show` chama `Orders.get_order!(conn.assigns.current_scope, id)`. O que acontece?",
opcoes: [
a: "Ele vê o pedido, porque está logado.",
b: "Ele é redirecionado para a página de login.",
c:
"`get_order!/2` procura um pedido com aquele `id` e com o `user_id` do Caio; não acha e levanta `Ecto.NoResultsError`, que vira uma resposta 404.",
d: "Ele vê a página do pedido vazia."
],
correta: :c,
explicacao:
"O escopo entra na própria consulta: `Repo.get_by!(Order, id: id, user_id: scope.user.id)`. Para o Caio, o pedido da Bia simplesmente não existe. O `phoenix_ecto` diz ao Plug que `Ecto.NoResultsError` corresponde ao status 404, então em produção ele vê a página \"Not Found\"."
},
carrinho_vazio: %{
texto:
"`complete_order/2` acabou de esvaziar o carrinho da Bia, mas o botão \"Complete order\" continua na página do carrinho. O que acontece se ela clicar nele?",
opcoes: [
a: "Nada: o navegador não envia o formulário.",
b: "O Phoenix responde com o status 422.",
c: "Um pedido de $0.00, sem itens, é criado: nada no código do guia impede isso.",
d: "O Ecto levanta um erro porque a lista de itens está vazia."
],
correta: :c,
explicacao:
"Com o carrinho vazio, a lista de itens é `[]` e o total é `0`. O insert é válido: o banco só exige que `total_price` não seja `NULL`. Uma regra como \"não existe pedido sem itens\" é regra de negócio, e quem precisa conhecê-la é o contexto `Orders`."
},
faq_estrutura: %{
texto:
"um colega quer dividir o `Catalog` em módulos menores: `ProductQueries` com as consultas e `ProductChangesets` com os changesets. Outra colega prefere tudo em `catalog.ex`. Segundo o FAQ, o que importa nessa decisão?",
opcoes: [
a: "O Phoenix exige um módulo de consultas por schema.",
b:
"Pouco: o que importa é a API pública do contexto e como os contextos conversam; os módulos internos são detalhes e podem variar de contexto para contexto.",
c: "Todos os contextos da aplicação precisam seguir exatamente a mesma organização.",
d: "Módulos separados deixam as consultas mais rápidas."
],
correta: :b,
explicacao:
"Quem usa o catálogo chama `Catalog.list_products/0` e não sabe se a consulta mora em `catalog.ex` ou em `ProductQueries`. Como a superfície da aplicação é a API do contexto, a organização interna pode mudar sem afetar ninguém. O FAQ compara os contextos a casas de um bairro: o importante é que estejam bem cuidadas e bem ligadas; por dentro, cada uma pode ser um pouco diferente."
}
}
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
%{
migracoes: ~S'''
defmodule Hello.Repo.Migrations.CreateOrders do
use Ecto.Migration
def change do
create table(:orders) do
add :total_price, :decimal, precision: 15, scale: 6, null: false
add :user_id, references(:users, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:orders, [:user_id])
end
end
Trilha.migracao(20250209214612, Hello.Repo.Migrations.CreateOrders)
# Na outra célula:
defmodule Hello.Repo.Migrations.CreateOrderLineItems do
use Ecto.Migration
def change do
create table(:order_line_items) do
add :price, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
end
end
Trilha.migracao(20250209215050, Hello.Repo.Migrations.CreateOrderLineItems)
''',
associacoes: ~S'''
defmodule Hello.Orders.Order do
use Ecto.Schema
import Ecto.Changeset
schema "orders" do
field :total_price, :decimal
belongs_to :user, Hello.Accounts.User
has_many :line_items, Hello.Orders.LineItem
has_many :products, through: [:line_items, :product]
timestamps(type: :utc_datetime)
end
@doc false
def changeset(order, attrs, user_scope) do
order
|> cast(attrs, [:total_price])
|> validate_required([:total_price])
|> put_change(:user_id, user_scope.user.id)
end
end
defmodule Hello.Orders.LineItem do
use Ecto.Schema
import Ecto.Changeset
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
belongs_to :order, Hello.Orders.Order
belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
@doc false
def changeset(line_item, attrs) do
line_item
|> cast(attrs, [:price, :quantity])
|> validate_required([:price, :quantity])
end
end
''',
complete_order: ~S'''
# Na célula do Hello.ShoppingCart (seção "A aplicação Hello"), uma função nova:
def prune_cart_items(%Scope{} = scope, %Cart{} = cart) do
{_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
{:ok, get_cart(scope)}
end
# Na célula do Hello.Orders, get_order!/2 com o preload:
def get_order!(%Scope{} = scope, id) do
Order
|> Repo.get_by!(id: id, user_id: scope.user.id)
|> Repo.preload(line_items: [:product])
end
# E, logo depois da linha `alias Hello.Orders.LineItem`:
alias Hello.ShoppingCart
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
true = cart.user_id == scope.user.id
line_items =
Enum.map(cart.items, fn item ->
%{
product_id: item.product_id,
price: item.product.price,
quantity: item.quantity
}
end)
order_changeset =
Ecto.Changeset.change(%Order{}, %{
user_id: scope.user.id,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
})
Repo.transact(fn ->
with {:ok, order} <- Repo.insert(order_changeset),
{:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
{:ok, order}
end
end)
|> case do
{:ok, order} ->
broadcast_order(scope, {:created, order})
{:ok, order}
{:error, reason} ->
{:error, reason}
end
end
''',
pagina_do_pedido: ~S'''
# Na célula do OrderController, a action show:
def show(conn, %{"id" => id}) do
order = Orders.get_order!(conn.assigns.current_scope, id)
render(conn, :show, order: order)
end
# Na célula de lib/hello_web/controllers/order_html.ex:
defmodule HelloWeb.OrderHTML do
use HelloWeb, :html
# order_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Thank you for your order!
<:subtitle>
<strong>Email: </strong>{@current_scope.user.email}
</:subtitle>
</.header>
<.table id="items" rows={@order.line_items}>
<:col :let={item} label="Title">{item.product.title}</:col>
<:col :let={item} label="Quantity">{item.quantity}</:col>
<:col :let={item} label="Price">
{HelloWeb.CartHTML.currency_to_str(item.price)}
</:col>
</.table>
<strong>Total price:</strong>
{HelloWeb.CartHTML.currency_to_str(@order.total_price)}
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>
"""
end
end
# Na cópia do CartHTML, depois da célula do OrderHTML, dentro do <.header>:
<.header>
My Cart
<:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
<:actions>
<.button href={~p"/orders"} method="post">
Complete order
</.button>
</:actions>
</.header>
''',
carrinho_vazio: ~S'''
# Na célula do Hello.Orders, uma cláusula antes da que já existe:
def complete_order(%Scope{}, %ShoppingCart.Cart{items: []}), do: {:error, :carrinho_vazio}
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
# ... como antes
end
# O OrderController não muda: qualquer {:error, _} já volta para /cart
# com a flash "There was an error processing your order".
''',
brecho: ~S'''
# priv/repo/migrations/20250323000000_create_compras.exs
defmodule Hello.Repo.Migrations.CreateCompras do
use Ecto.Migration
def change do
create table(:compras) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :total_centavos, :integer, null: false
timestamps(type: :utc_datetime)
end
create index(:compras, [:user_id])
create table(:compra_itens) do
add :compra_id, references(:compras, on_delete: :delete_all), null: false
add :produto_id, references(:produtos, on_delete: :nothing), null: false
add :preco_centavos, :integer, null: false
timestamps(type: :utc_datetime)
end
create index(:compra_itens, [:compra_id])
create unique_index(:compra_itens, [:produto_id])
end
end
Trilha.migracao(20250323000000, Hello.Repo.Migrations.CreateCompras)
# Depois: Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/compras/compra.ex, lib/hello/compras/item.ex e lib/hello/compras.ex
defmodule Hello.Compras.Compra do
use Ecto.Schema
schema "compras" do
field :total_centavos, :integer
belongs_to :user, Hello.Accounts.User
has_many :itens, Hello.Compras.Item
timestamps(type: :utc_datetime)
end
end
defmodule Hello.Compras.Item do
use Ecto.Schema
schema "compra_itens" do
field :preco_centavos, :integer
belongs_to :compra, Hello.Compras.Compra
belongs_to :produto, Hello.Vitrine.Produto
timestamps(type: :utc_datetime)
end
end
defmodule Hello.Compras do
@moduledoc """
As compras do Brechó: cada compra leva uma ou mais peças de uma vez.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Accounts.Scope
alias Hello.Compras.Compra
alias Hello.Vitrine.Produto
def comprar(%Scope{} = scope, produto_ids) do
ids = Enum.uniq(produto_ids)
Repo.transact(fn ->
disponiveis = from p in Produto, where: p.id in ^ids and not p.vendido, select: p
case Repo.update_all(disponiveis, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{quantidade, produtos} when quantidade == length(ids) ->
itens = Enum.map(produtos, &%{produto_id: &1.id, preco_centavos: &1.preco_centavos})
%Compra{}
|> Ecto.Changeset.change(
user_id: scope.user.id,
total_centavos: Enum.sum_by(produtos, & &1.preco_centavos),
itens: itens
)
|> Repo.insert()
_ ->
{:error, :indisponivel}
end
end)
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
# As conferências fazem dezenas de consultas; o log delas esconderia o resultado.
nivel = Logger.level()
Logger.configure(level: :none)
resultado =
try do
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)
after
Logger.configure(level: nivel)
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
%{
migracoes: [
{"As duas migrations estão registradas com as versões do guia.",
fn ->
esperadas = [
{20250209214612, Hello.Repo.Migrations.CreateOrders},
{20250209215050, Hello.Repo.Migrations.CreateOrderLineItems}
]
case esperadas -- migracoes() do
[] -> :ok
faltando -> {:erro, "Avalie as células das duas migrations; cada uma termina com `Trilha.migracao/2`. Faltam: `#{inspect(faltando)}`."}
end
end},
{"As tabelas `orders` e `order_line_items` existem.",
fn ->
with :ok <- colunas("orders", ~w(id total_price user_id inserted_at updated_at)) do
colunas("order_line_items", ~w(id price quantity order_id product_id inserted_at updated_at))
end
end},
{"`orders.total_price` é `numeric(15,6)` e não aceita `NULL`.", fn -> coluna_de_dinheiro("orders", "total_price") end},
{"`order_line_items.price` é `numeric(15,6)` e não aceita `NULL`.", fn -> coluna_de_dinheiro("order_line_items", "price") end}
],
associacoes: [
{"`Order` tem `belongs_to :user`, ligado a `Hello.Accounts.User`.",
fn -> associacao(Hello.Orders.Order, :user, Ecto.Association.BelongsTo, Hello.Accounts.User) end},
{"`Order` tem `has_many :line_items`, ligado a `Hello.Orders.LineItem`.",
fn -> associacao(Hello.Orders.Order, :line_items, Ecto.Association.Has, Hello.Orders.LineItem) end},
{"`Order` tem `has_many :products, through: [:line_items, :product]`.",
fn ->
case apply(Hello.Orders.Order, :__schema__, [:association, :products]) do
%Ecto.Association.HasThrough{through: [:line_items, :product]} -> :ok
nil -> {:erro, "Falta a associação `:products` no `Order`."}
outra -> {:erro, "`:products` existe, mas como `#{inspect(outra.__struct__)}`. Use `has_many :products, through: [:line_items, :product]`."}
end
end},
{"`LineItem` tem `belongs_to :order` e `belongs_to :product`, ligado a `Hello.Catalog.Product`.",
fn ->
with :ok <- associacao(Hello.Orders.LineItem, :order, Ecto.Association.BelongsTo, Hello.Orders.Order) do
associacao(Hello.Orders.LineItem, :product, Ecto.Association.BelongsTo, Hello.Catalog.Product)
end
end},
{"`Repo.preload(order, :products)` traz os produtos pelos itens do pedido.",
fn ->
[{p1, _, _}, {p2, _, _}] = produtos_do_catalogo()
resultado =
apply(@repo, :transaction, [
fn ->
itens = [
struct(Hello.Orders.LineItem, price: Decimal.new("1"), quantity: 1, product_id: p1),
struct(Hello.Orders.LineItem, price: Decimal.new("1"), quantity: 1, product_id: p2)
]
pedido = apply(@repo, :insert!, [struct(Hello.Orders.Order, total_price: Decimal.new("2"), line_items: itens)])
produtos = apply(@repo, :preload, [pedido, :products]).products
apply(@repo, :rollback, [produtos |> Enum.map(& &1.id) |> Enum.sort()])
end
])
case resultado do
{:error, [^p1, ^p2]} -> :ok
{:error, outros} -> {:erro, "O preload trouxe os produtos `#{inspect(outros)}`; o esperado era `#{inspect([p1, p2])}`."}
end
end}
],
complete_order: [
{"`ShoppingCart.prune_cart_items/2` e `Orders.complete_order/2` existem.",
fn ->
with :ok <- exporta(Hello.ShoppingCart, prune_cart_items: 2) do
exporta(Hello.Orders, complete_order: 2)
end
end},
{"`complete_order/2` grava o pedido com o total do carrinho e um item por produto, com o preço e a quantidade, e esvazia o carrinho.",
fn ->
conferindo(fn ->
[{p1, _, preco1}, {p2, _, preco2}] = produtos_do_catalogo()
{scope, cart} = carrinho_de_conferencia("conferencia-pedido@example.com", [{p1, 2}, {p2, 1}])
total_esperado = preco1 |> Decimal.mult(2) |> Decimal.add(preco2)
case apply(Hello.Orders, :complete_order, [scope, cart]) do
{:ok, %{id: id}} ->
[[total, dono]] = sql("SELECT total_price, user_id FROM orders WHERE id = $1", [id]).rows
itens = sql("SELECT product_id, price, quantity FROM order_line_items WHERE order_id = $1 ORDER BY product_id", [id]).rows
restantes = apply(Hello.ShoppingCart, :get_cart, [scope]).items
cond do
dono != scope.user.id ->
{:erro, "O pedido foi gravado com `user_id` #{inspect(dono)}, e não com o do usuário do escopo."}
not Decimal.equal?(total, total_esperado) ->
{:erro, "O total gravado foi #{total}; o esperado era #{total_esperado} (2 × #{preco1} + #{preco2}). Use `ShoppingCart.total_cart_price(cart)`."}
length(itens) != 2 ->
{:erro, "O pedido tem #{length(itens)} itens; o carrinho tinha 2 produtos. Passe a lista `line_items` no `Ecto.Changeset.change/2`."}
not match?([[^p1, _, 2], [^p2, _, 1]], itens) or
not Enum.all?(Enum.zip(itens, [preco1, preco2]), fn {[_, preco, _], esperado} -> Decimal.equal?(preco, esperado) end) ->
{:erro, "Os itens gravados foram `#{inspect(itens)}`; cada item precisa do `product_id`, do preço do produto e da quantidade do carrinho."}
restantes != [] ->
{:erro, "O pedido foi gravado, mas o carrinho ainda tem #{length(restantes)} itens. Chame `ShoppingCart.prune_cart_items/2` dentro da transação."}
true ->
:ok
end
outro ->
{:erro, "`complete_order/2` devolveu `#{inspect(outro, limit: 6)}`; o esperado é `{:ok, %Order{}}`."}
end
end)
end},
{"`Orders.get_order!/2` devolve o pedido com os itens e os produtos carregados.",
fn ->
conferindo(fn ->
[{p1, titulo, _} | _] = produtos_do_catalogo()
{scope, cart} = carrinho_de_conferencia("conferencia-preload@example.com", [{p1, 1}])
{:ok, %{id: id}} = apply(Hello.Orders, :complete_order, [scope, cart])
case apply(Hello.Orders, :get_order!, [scope, id]).line_items do
[%{product: %{title: ^titulo}}] -> :ok
%Ecto.Association.NotLoaded{} -> {:erro, "Os itens não vieram carregados. Acrescente `|> Repo.preload(line_items: [:product])` em `get_order!/2`."}
[%{product: %Ecto.Association.NotLoaded{}}] -> {:erro, "Os itens vieram, mas sem o produto. O preload é `line_items: [:product]`."}
outro -> {:erro, "Os itens vieram como `#{inspect(outro, limit: 4)}`."}
end
end)
end}
],
pagina_do_pedido: [
{"Com itens no carrinho, `GET /cart` mostra o botão \"Complete order\", que envia `POST /orders`.",
fn ->
conferindo(fn ->
[{p1, _, _} | _] = produtos_do_catalogo()
{scope, _cart} = carrinho_de_conferencia("conferencia-web@example.com", [{p1, 1}])
case requisicao(:get, "/cart", nil, logado(scope)) do
{:ok, %{status: 200, resp_body: corpo}} ->
cond do
not (corpo =~ "Complete order") ->
{:erro, "A página do carrinho não tem o botão. Ele vai no slot `:actions` do `<.header>`, na cópia do `CartHTML` que fica logo depois da célula do `OrderHTML`."}
not (corpo =~ ~s(data-method="post")) or not (corpo =~ ~s(href="/orders")) ->
{:erro, "O botão existe, mas não envia `POST /orders`. Use `<.button href={~p\"/orders\"} method=\"post\">`."}
true ->
:ok
end
{:ok, conn} ->
{:erro, "`GET /cart` respondeu com o status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"`POST /orders` completa o pedido e redireciona para `/orders/ID`, que mostra o pedido ao dono.",
fn ->
conferindo(fn ->
[{p1, titulo, _} | _] = produtos_do_catalogo()
{scope, _cart} = carrinho_de_conferencia("conferencia-web@example.com", [{p1, 1}])
with {:ok, %{status: 302} = resp} <- requisicao(:post, "/orders", nil, logado(scope)),
["/orders/" <> id] <- Plug.Conn.get_resp_header(resp, "location"),
{:ok, %{status: 200, resp_body: corpo}} <- requisicao(:get, "/orders/" <> id, nil, logado(scope)) do
cond do
not (corpo =~ "Thank you for your order!") -> {:erro, "A página do pedido não tem o título \"Thank you for your order!\"."}
not (corpo =~ titulo) -> {:erro, "A página do pedido não mostra o título do produto, `#{titulo}`."}
not (corpo =~ "conferencia-web@example.com") -> {:erro, "A página do pedido não mostra o e-mail do usuário (`@current_scope.user.email`)."}
true -> :ok
end
else
{:ok, %{status: status} = resp} ->
{:erro, "Veio o status `#{status}`, com `location` #{inspect(Plug.Conn.get_resp_header(resp, "location"))}."}
{:erro, erro} ->
{:erro, explicar(erro)}
outro ->
{:erro, "O redirect foi para `#{inspect(outro)}`; o esperado é `/orders/ID`."}
end
end)
end},
{"Outro usuário que abre o mesmo endereço recebe `Ecto.NoResultsError`, que vira 404.",
fn ->
conferindo(fn ->
[{p1, _, _} | _] = produtos_do_catalogo()
{dono, cart} = carrinho_de_conferencia("conferencia-dono@example.com", [{p1, 1}])
{:ok, %{id: id}} = apply(Hello.Orders, :complete_order, [dono, cart])
intruso = usuario_de_conferencia("conferencia-intruso@example.com")
case requisicao(:get, "/orders/#{id}", nil, logado(intruso)) do
{:erro, %Ecto.NoResultsError{}} -> :ok
{:ok, conn} -> {:erro, "Outro usuário recebeu o status `#{conn.status}`. Use `Orders.get_order!(conn.assigns.current_scope, id)` na action `show`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end)
end}
],
carrinho_vazio: [
{"`complete_order/2` com o carrinho vazio devolve `{:error, :carrinho_vazio}` e não grava pedido.",
fn ->
conferindo(fn ->
{scope, cart} = carrinho_de_conferencia("conferencia-vazio@example.com", [])
resultado = apply(Hello.Orders, :complete_order, [scope, cart])
pedidos = coluna("SELECT count(*) FROM orders WHERE user_id = $1", [scope.user.id])
cond do
resultado != {:error, :carrinho_vazio} ->
{:erro, "Com o carrinho vazio, `complete_order/2` devolveu `#{inspect(resultado, limit: 5)}`."}
pedidos != [0] ->
{:erro, "A função devolveu o erro, mas um pedido foi gravado. Confira o carrinho antes de inserir."}
true ->
:ok
end
end)
end},
{"`POST /orders` com o carrinho vazio volta para `/cart` com a flash de erro.",
fn ->
conferindo(fn ->
{scope, _cart} = carrinho_de_conferencia("conferencia-vazio@example.com", [])
case requisicao(:post, "/orders", nil, logado(scope)) do
{:ok, %{status: 302} = conn} ->
cond do
Plug.Conn.get_resp_header(conn, "location") != ["/cart"] ->
{:erro, "O redirect foi para `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`; o esperado é `/cart`."}
Phoenix.Flash.get(conn.assigns.flash, :error) == nil ->
{:erro, "O redirect está certo, mas não há flash de erro."}
true ->
:ok
end
{:ok, conn} ->
{:erro, "A resposta veio com o status `#{conn.status}`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end)
end},
{"Um carrinho com itens continua virando pedido.",
fn ->
conferindo(fn ->
[{p1, _, _} | _] = produtos_do_catalogo()
{scope, cart} = carrinho_de_conferencia("conferencia-cheio@example.com", [{p1, 1}])
case apply(Hello.Orders, :complete_order, [scope, cart]) do
{:ok, _} -> :ok
outro -> {:erro, "Com um item no carrinho, `complete_order/2` devolveu `#{inspect(outro, limit: 5)}`."}
end
end)
end}
],
brecho: [
{"A tabela `compras` existe, com `user_id`, `total_centavos` e os timestamps.",
fn -> colunas("compras", ~w(id user_id total_centavos inserted_at updated_at)) end},
{"`Hello.Compras.comprar/2` existe.", fn -> exporta(Hello.Compras, comprar: 2) end},
{"Uma compra de duas peças devolve `{:ok, compra}`, marca as duas como vendidas e grava o total para o usuário.",
fn ->
conferindo(fn ->
scope = usuario_de_conferencia("conferencia-compra@example.com")
saia = peca("Conferência Saia", 3000)
blusa = peca("Conferência Blusa", 4500)
case apply(Hello.Compras, :comprar, [scope, [saia, blusa]]) do
{:ok, %{id: id}} ->
cond do
coluna("SELECT count(*) FROM produtos WHERE id = ANY($1) AND vendido", [[saia, blusa]]) != [2] ->
{:erro, "A compra deu certo, mas as peças não ficaram com `vendido` verdadeiro."}
sql("SELECT total_centavos, user_id FROM compras WHERE id = $1", [id]).rows != [[7500, scope.user.id]] ->
{:erro, "A compra gravada foi `#{inspect(sql("SELECT total_centavos, user_id FROM compras WHERE id = $1", [id]).rows)}`; o esperado é total 7500 para o usuário #{scope.user.id}."}
true ->
:ok
end
outro ->
{:erro, "`comprar/2` devolveu `#{inspect(outro, limit: 6)}`; o esperado é `{:ok, compra}`."}
end
end)
end},
{"Uma compra com uma peça já vendida devolve `{:error, :indisponivel}` e não muda nada.",
fn ->
conferindo(fn ->
scope = usuario_de_conferencia("conferencia-azar@example.com")
casaco = peca("Conferência Casaco", 9000)
vendida = peca("Conferência Vendida", 2000, true)
resultado = apply(Hello.Compras, :comprar, [scope, [casaco, vendida]])
cond do
resultado != {:error, :indisponivel} ->
{:erro, "Com uma peça já vendida, `comprar/2` devolveu `#{inspect(resultado, limit: 6)}`."}
coluna("SELECT vendido FROM produtos WHERE id = $1", [casaco]) != [false] ->
{:erro, "A compra falhou, mas o casaco ficou marcado como vendido. A marcação precisa ser desfeita: faça tudo dentro de `Repo.transact/2` e devolva `{:error, :indisponivel}` de dentro dela."}
coluna("SELECT count(*) FROM compras WHERE user_id = $1", [scope.user.id]) != [0] ->
{:erro, "A compra falhou, mas uma linha foi gravada em `compras`."}
true ->
:ok
end
end)
end},
{"Seis pessoas tentam comprar a mesma peça ao mesmo tempo: só uma consegue.",
fn ->
conferindo(fn ->
disputada = peca("Conferência Disputada", 5000)
escopos = for i <- 1..6, do: usuario_de_conferencia("conferencia-corrida-#{i}@example.com")
resultados =
escopos
|> Task.async_stream(fn scope -> apply(Hello.Compras, :comprar, [scope, [disputada]]) end, max_concurrency: 6, timeout: 15_000)
|> Enum.map(fn {:ok, resultado} -> resultado end)
certas = Enum.count(resultados, &match?({:ok, _}, &1))
gravadas = coluna("SELECT count(*) FROM compras WHERE user_id = ANY($1)", [Enum.map(escopos, & &1.user.id)])
cond do
certas != 1 -> {:erro, "#{certas} compras deram certo. A verificação de `vendido` e a marcação precisam estar no mesmo `update_all`, com `where: not p.vendido`."}
gravadas != [1] -> {:erro, "Uma compra deu certo, mas há #{hd(gravadas)} linhas em `compras` para esses usuários."}
true -> :ok
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 coluna_de_dinheiro(tabela, campo) do
consulta = """
SELECT format_type(atttypid, atttypmod), attnotnull FROM pg_attribute
WHERE attrelid = to_regclass($1) AND attname = $2 AND NOT attisdropped
"""
case sql(consulta, [tabela, campo]).rows do
[["numeric(15,6)", true]] ->
:ok
[[tipo, nao_nulo]] ->
{:erro,
"A coluna está como `#{tipo}`#{if nao_nulo, do: " not null", else: ", aceitando NULL"}. Se o migrate rodou antes da mudança, desfaça as duas migrations com " <>
"`Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, to: 20250209214612)`, avalie de novo as células das migrations e rode o migrate."}
[] ->
{:erro, "A coluna `#{campo}` não existe em `#{tabela}`."}
end
end
defp associacao(schema, nome, tipo, relacionado) do
case apply(schema, :__schema__, [:association, nome]) do
%{__struct__: ^tipo, related: ^relacionado} = assoc ->
if tipo == Ecto.Association.Has and assoc.cardinality != :many,
do: {:erro, "`#{inspect(nome)}` é `has_one`; o pedido tem vários itens, então é `has_many`."},
else: :ok
nil ->
{:erro, "`#{inspect(schema)}` não tem a associação `#{inspect(nome)}`. Troque o `field` pela associação e avalie a célula."}
outra ->
{:erro, "`#{inspect(nome)}` está declarada como `#{inspect(outra.__struct__)}` para `#{inspect(outra.related)}`."}
end
end
# Os dois primeiros produtos do catálogo, como {id, título, preço}.
defp produtos_do_catalogo do
case sql("SELECT id, title, price FROM products ORDER BY id LIMIT 2").rows do
[[i1, t1, p1], [i2, t2, p2]] -> [{i1, t1, p1}, {i2, t2, p2}]
_ -> raise "O catálogo precisa de dois produtos. Avalie a célula dos seeds, na seção \"Antes de começar\"."
end
end
defp usuario_de_conferencia(email) do
{:ok, user} = apply(Hello.Accounts, :register_user, [%{email: email}])
apply(Hello.Accounts.Scope, :for_user, [user])
end
# Um usuário novo com um carrinho; `itens` é uma lista de {product_id, quantidade}.
defp carrinho_de_conferencia(email, itens) do
scope = usuario_de_conferencia(email)
{:ok, cart} = apply(Hello.ShoppingCart, :create_cart, [scope, %{}])
for {produto_id, quantidade} <- itens, _ <- 1..quantidade//1 do
{:ok, _} = apply(Hello.ShoppingCart, :add_item_to_cart, [scope, cart, produto_id])
end
{scope, apply(Hello.ShoppingCart, :get_cart, [scope])}
end
# Um conn com o usuário do escopo na sessão, como depois do login.
defp logado(scope) do
Phoenix.ConnTest.build_conn() |> Plug.Test.init_test_session(%{user_id: scope.user.id})
end
defp peca(nome, preco, vendido \\ false) do
[[id]] =
sql(
"INSERT INTO produtos (nome, preco_centavos, vendido, inserted_at, updated_at) VALUES ($1, $2, $3, now(), now()) RETURNING id",
[nome, preco, vendido]
).rows
id
end
# Roda o teste e, aconteça o que acontecer, apaga os usuários e as peças criados por ele.
defp conferindo(teste) do
teste.()
after
limpar_conferencia()
end
defp limpar_conferencia do
usuarios = coluna("SELECT id FROM users WHERE email LIKE 'conferencia%'")
if usuarios != [] do
if tabela?("order_line_items") do
sql("DELETE FROM order_line_items WHERE order_id IN (SELECT id FROM orders WHERE user_id = ANY($1))", [usuarios])
end
if tabela?("orders"), do: sql("DELETE FROM orders WHERE user_id = ANY($1)", [usuarios])
if tabela?("compras") do
compras = coluna("SELECT id FROM compras WHERE user_id = ANY($1)", [usuarios])
for [tabela, campo] <- referencias("compras"), do: sql("DELETE FROM #{tabela} WHERE #{campo} = ANY($1)", [compras])
sql("DELETE FROM compras WHERE id = ANY($1)", [compras])
end
# Os carrinhos e os itens deles somem junto, pelo on_delete: :delete_all.
sql("DELETE FROM users WHERE id = ANY($1)", [usuarios])
end
if tabela?("produtos") do
pecas = coluna("SELECT id FROM produtos WHERE nome LIKE 'Conferência%'")
for [tabela, campo] <- referencias("produtos"), do: sql("DELETE FROM #{tabela} WHERE #{campo} = ANY($1)", [pecas])
sql("DELETE FROM produtos WHERE id = ANY($1)", [pecas])
end
end
defp tabela?(nome), do: coluna("SELECT to_regclass($1)::text", [nome]) == [nome]
# As tabelas (e colunas) com chave estrangeira apontando para `tabela`.
defp referencias(tabela) do
sql(
"""
SELECT c.conrelid::regclass::text, a.attname
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f' AND c.confrelid = to_regclass($1)
""",
[tabela]
).rows
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Avalie a célula do router com a rota nova."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."
true ->
"`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
"O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
end
defp explicar(%Phoenix.ActionClauseError{}) do
"Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(%ArgumentError{message: mensagem}) do
"`ArgumentError`: #{mensagem}"
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha os dois últimos guias de modelagem de dados da documentação do Phoenix 1.8: Bringing It Home, em que o carrinho vira pedido, e o FAQ sobre contextos. Ele fecha o módulo 6 da trilha.
A aplicação começa onde o notebook 10 terminou: o contexto Catalog, com produtos e categorias; a autenticação; e o contexto ShoppingCart, com o carrinho de cada usuário. Ao longo do notebook você cria o quarto contexto, Orders, que transforma o carrinho em pedido.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, os exercícios "Sua vez" têm uma conferência automática e uma solução escondida, e os erros fazem parte do conteúdo. Trilha.navegar/4 faz requisições como um navegador: guarda os cookies de cada visitante (aqui, a Bia e o Caio), segue os redirects e mostra a flash e o texto da página. Você também pode abrir http://localhost:4010 no navegador e entrar com qualquer e-mail.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
Estas células trazem a aplicação no estado em que o guia "Cross-context Boundaries" termina. O código dos contextos e dos controllers já apareceu nos notebooks 08 a 10; aqui ele vem sem os @doc gerados, para encurtar.
Duas diferenças em relação a um projeto de verdade, as mesmas do notebook 10:
- Autenticação reduzida. O
mix phx.gen.authgera login por link enviado por e-mail, senha opcional (com a bibliotecabcrypt_elixir, que precisa de compilador C) e tokens de sessão guardados na tabelausers_tokens. Aqui, os módulos e as funções que o resto da aplicação usa têm os mesmos nomes do gerado (Hello.Accounts,Hello.Accounts.Scope,HelloWeb.UserAuth,fetch_current_scope_for_user,require_authenticated_user), mas o login só pede o e-mail, cria a conta se ela não existir, e a sessão guarda ouser_id. Não use isso fora de um notebook. O módulo de autenticação da trilha estuda o gerador completo. - O router aparece duas vezes. O router completo importa o
HelloWeb.UserAuth, e oUserAuthusa~p, que confere cada caminho no router durante a compilação. No projeto, o compilador resolve esse ciclo sozinho. No notebook, uma primeira versão do router, só com as rotas que oUserAuthusa, vem antes dele; a versão completa vem depois.
config/dev.exs: a configuração do banco, a mesma dos notebooks 07 a 10. 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.
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, primeira versão: só as duas rotas que o HelloWeb.UserAuth cita com ~p. Avaliar o router mostra avisos de que os controllers ainda não existem (init/1 is undefined). É esperado: eles vêm mais abaixo.
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
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/users/log-in", UserSessionController, :new
end
end
lib/hello_web/endpoint.ex: como nos notebooks 08 a 10, o Plug.Static serve o phoenix_html.js direto do pacote, no lugar do app.js compilado de um projeto. É ele que faz os links com method="post" e method="delete" 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: o arquivo gerado pelo Phoenix 1.8.16, sem o Gettext (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
priv/repo/migrations/: as seis migrations dos notebooks 08 a 10, numa célula só. A dos usuários é a da autenticação reduzida: só a tabela users, com o e-mail.
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
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
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
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(20250201185747, Hello.Repo.Migrations.CreateProducts)
Trilha.migracao(20250203192325, Hello.Repo.Migrations.CreateCategories)
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)
Trilha.migracao(20250205200000, Hello.Repo.Migrations.CreateUsersAuthTables)
Trilha.migracao(20250205203128, Hello.Repo.Migrations.CreateCarts)
Trilha.migracao(20250205213410, Hello.Repo.Migrations.CreateCartItems)
Trilha.migracoes()
lib/hello/accounts/user.ex, lib/hello/accounts.ex e lib/hello/accounts/scope.ex: a autenticação reduzida. O Scope é o arquivo gerado pelo phx.gen.auth, igual.
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/catalog/product.ex, lib/hello/catalog/category.ex e lib/hello/catalog.ex: o catálogo no fim do notebook 09.
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
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 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/shopping_cart/cart.ex, cart_item.ex e lib/hello/shopping_cart.ex: o carrinho no fim do notebook 10. Um dos exercícios deste notebook acrescenta uma função a esta célula.
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
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
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
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
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 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
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
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
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
def change_cart(%Scope{} = scope, %Cart{} = cart, attrs \\ %{}) do
true = cart.user_id == scope.user.id
Cart.changeset(cart, attrs, scope)
end
def list_cart_items do
Repo.all(CartItem)
end
def get_cart_item!(id), do: Repo.get!(CartItem, id)
def create_cart_item(attrs) do
%CartItem{}
|> CartItem.changeset(attrs)
|> Repo.insert()
end
def update_cart_item(%CartItem{} = cart_item, attrs) do
cart_item
|> CartItem.changeset(attrs)
|> Repo.update()
end
def delete_cart_item(%CartItem{} = cart_item) do
Repo.delete(cart_item)
end
def change_cart_item(%CartItem{} = cart_item, attrs \\ %{}) do
CartItem.changeset(cart_item, attrs)
end
end
lib/hello_web/user_auth.ex: os plugs de autenticação, com os nomes do gerado.
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
lib/hello_web/router.ex, versão completa: o estado do fim do notebook 10, com o plug fetch_current_cart e as rotas do carrinho e do login. As três rotas de /produtos são do Brechó, o mini-marketplace da trilha (notebook 08), usadas no desafio do fim. De novo, os avisos init/1 is undefined são esperados.
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
# Brechó (notebook 08)
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
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/components/layouts.ex: o root layout tem o menu do usuário, que o phx.gen.auth injeta no root.html.heex. Como nos notebooks anteriores, o Tailwind e o daisyUI vêm de uma CDN.
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
lib/hello_web/controllers/: as páginas de erro, a página inicial e o login reduzido.
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
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
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/controllers/product_controller.ex e product_html.ex: as páginas do catálogo, com o botão "Add to cart" do notebook 10.
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
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>
<.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
Add to cart
</.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
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
lib/hello_web/controllers/cart_item_controller.ex, cart_controller.ex e cart_html.ex: o carrinho.
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
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
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
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL dos notebooks 07 a 10, 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 quando os dados de um usuário mudam.
{: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 e os cookies dos visitantes, 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()
Rode as seis migrations (é o mix ecto.migrate):
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
priv/repo/seeds.exs: as categorias do notebook 09 e os dois produtos dos guias.
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.00",
views: 0
})
{:ok, _} =
Hello.Catalog.create_product(%{
title: "Elixir Flashcards",
description: "Flash card set for the Elixir programming language",
price: "5.00",
views: 0
})
Hello.Catalog.list_products() |> Enum.map(&{&1.id, &1.title, &1.price})
A Bia e o carrinho dela
A Bia entra com o e-mail bia@example.com (a conta é criada no primeiro login) e põe no carrinho dois exemplares do livro e um baralho de flashcards. Trilha.visitar/4 faz cada requisição sem mostrar nada; a última célula mostra o carrinho como a Bia o vê.
Trilha.visitar("bia", :post, "/users/log-in", %{"user" => %{"email" => "bia@example.com"}})
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=2")
Trilha.navegar("bia", :get, "/cart")
Dois livros a $15.00 dão $30.00, mais o baralho de $5.00: o total é $35.00. Os números entre colchetes são os valores dos campos de quantidade do formulário.
Onde mora o pedido
Na documentação: Bringing It Home
Com o Catalog e o ShoppingCart, a loja mostra produtos e guarda o carrinho de cada usuário. Falta o último passo: fechar a compra. O guia não vai até o pagamento nem a entrega, mas monta o começo, o pedido. Como nos guias anteriores, a primeira decisão é onde esse código vai morar.
Trilha.pergunta(:onde_mora)
O guia pesa as duas candidatas óbvias. O catálogo está fora: ele cuida da exibição dos produtos. O carrinho é mais tentador, porque o pedido nasce dele. Mas o pedido tem dados que o carrinho não tem e regras que o carrinho não segue:
- dados próprios: o preço de cada item no momento do pagamento. O preço de um produto pode mudar amanhã; o que foi cobrado, não;
- regras próprias: um item sem estoque pode ficar no carrinho, mas um pedido com esse item não pode ser completado.
Por isso o pedido ganha o seu próprio contexto, Orders.
Gerando o contexto Orders
Na documentação: Bringing It Home, a partir de "Naming wise, Orders clearly defines our context". O guia não tem subseções; os títulos deste notebook dividem o texto dele.
O guia gera o contexto com:
$ mix phx.gen.context Orders Order orders total_price:decimal
O comando não diz nada sobre usuários. Antes de ver o que ele gerou:
Trilha.pergunta(:escopo_padrao)
Esta é a configuração que o mix phx.gen.auth acrescenta ao config/config.exs, copiada de um projeto gerado com o Phoenix 1.8.16:
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
]
Cada chave responde a uma pergunta que o gerador faz: qual módulo é o escopo (module), em qual assign do conn ele chega (assign_key), como achar o id do dono a partir do escopo (access_path, ou seja, scope.user.id), qual coluna guarda esse id e de que tipo (schema_key, schema_type), para qual tabela ela aponta (schema_table) e como os testes gerados criam um usuário logado (as duas últimas). default: true faz o escopo user valer para todo gerador que não receba --scope nem --no-scope.
A saída do gerador:
* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20250209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
priv/repo/migrations/20250209214612_create_orders.exs, como o gerador a escreve. A versão é a do guia. O escopo trouxe a coluna user_id, que aponta para users e apaga os pedidos quando o usuário é apagado (on_delete: :delete_all), e um índice nela. O guia mostra a mesma linha com type: :id, que é o tipo padrão; o gerador do 1.8.16 não escreve essa opção.
defmodule Hello.Repo.Migrations.CreateOrders do
use Ecto.Migration
def change do
create table(:orders) do
add :total_price, :decimal
add :user_id, references(:users, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
create index(:orders, [:user_id])
end
end
Trilha.migracao(20250209214612, Hello.Repo.Migrations.CreateOrders)
A tabela de pedidos sozinha guarda pouco: quem comprou e quanto pagou. O preço de cada produto no momento da compra fica em outra tabela, a dos itens do pedido. O guia gera o schema LineItem no mesmo contexto, desta vez com --no-scope: os itens pertencem a um pedido, e o pedido já pertence a um usuário.
$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20250209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex
priv/repo/migrations/20250209215050_create_order_line_items.exs, como gerada. Os campos references viraram chaves estrangeiras com índice. Ao contrário dos itens do carrinho, aqui o guia mantém on_delete: :nothing: um pedido é um registro do que aconteceu e não deve sumir em cascata.
defmodule Hello.Repo.Migrations.CreateOrderLineItems do
use Ecto.Migration
def change do
create table(:order_line_items) do
add :price, :decimal
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
end
end
Trilha.migracao(20250209215050, Hello.Repo.Migrations.CreateOrderLineItems)
Sua vez: as colunas de dinheiro
Antes de rodar o mix ecto.migrate, o guia ajusta as duas migrations da mesma forma que fez com products.price e cart_items.price_when_carted: o dinheiro vira :decimal com precision: 15 e scale: 6, sem perda de precisão nas contas, e com null: false, porque todo pedido e todo item precisam de um preço.
Edite as duas células acima: total_price em orders e price em order_line_items. Avalie as duas e só então a célula abaixo.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Trilha.descrever("orders")
A coluna total_price deve aparecer como numeric(15,6) e not null. A chave estrangeira de user_id aparece como ON DELETE CASCADE, o nome que o PostgreSQL dá ao on_delete: :delete_all. Se o migrate rodou antes da edição, a tabela foi criada do jeito antigo e rodar de novo não muda nada: desfaça as duas migrations com Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, to: 20250209214612), avalie de novo as duas células e rode o migrate.
Trilha.conferir(:migracoes)
Trilha.solucao(:migracoes)
As associações
Na documentação: Bringing It Home, a partir de "With our migration in place, let's wire up our orders and line items associations".
lib/hello/orders/order.ex e lib/hello/orders/line_item.ex, como gerados. O escopo pôs field :user_id, :id no Order e um terceiro argumento no changeset/3: o changeset recebe o escopo e grava o user_id com put_change/3, sem aceitá-lo dos parâmetros. Assim, ninguém cria um pedido em nome de outra pessoa mandando um user_id no formulário.
defmodule Hello.Orders.Order do
use Ecto.Schema
import Ecto.Changeset
schema "orders" do
field :total_price, :decimal
field :user_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(order, attrs, user_scope) do
order
|> cast(attrs, [:total_price])
|> validate_required([:total_price])
|> put_change(:user_id, user_scope.user.id)
end
end
defmodule Hello.Orders.LineItem do
use Ecto.Schema
import Ecto.Changeset
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
field :order_id, :id
field :product_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(line_item, attrs) do
line_item
|> cast(attrs, [:price, :quantity])
|> validate_required([:price, :quantity])
end
end
Os campos *_id são só números. Para navegar de um pedido aos seus itens e aos produtos, o guia troca esses campos por associações. Uma delas é nova na trilha:
Trilha.pergunta(:has_many_through)
Sua vez: ligar pedido, itens, produtos e usuário
Edite a célula acima como o guia indica:
schema "orders" do
field :total_price, :decimal
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :line_items, Hello.Orders.LineItem
+ has_many :products, through: [:line_items, :product]
timestamps(type: :utc_datetime)
end
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
- field :order_id, :id
- field :product_id, :id
+ belongs_to :order, Hello.Orders.Order
+ belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
belongs_to :user continua criando o campo user_id, e o changeset/3 gerado segue funcionando. has_many :line_items é o lado oposto do belongs_to :order. has_many :products, through: [:line_items, :product] é o caminho de dois passos: do pedido aos itens, de cada item ao produto.
O belongs_to :product aponta para um schema de outro contexto, Hello.Catalog.Product. É a mesma escolha que o notebook 10 fez com os itens do carrinho: quando a dependência entre os dados é real, um join no banco é aceitável, e o contexto Orders não precisa chamar o Catalog para cada produto.
A conferência insere um pedido com dois itens dentro de uma transação, carrega :products pelo caminho through e desfaz tudo no fim.
Trilha.conferir(:associacoes)
Trilha.solucao(:associacoes)
O contexto Orders
Na documentação: Bringing It Home, a partir de "Before we render information about our orders".
lib/hello/orders.ex: o contexto como os dois geradores o deixaram, com os @doc gerados. Ele fica abaixo dos schemas de propósito: as funções usam %Order{}, e a struct precisa estar na sua forma final quando esta célula compila. Se você mudar os schemas de novo, avalie esta célula também (o notebook 09 mostrou o MatchError que aparece quando uma struct fica desatualizada).
defmodule Hello.Orders do
@moduledoc """
The Orders context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Orders.Order
alias Hello.Accounts.Scope
@doc """
Subscribes to scoped notifications about any order changes.
The broadcasted messages match the pattern:
* {:created, %Order{}}
* {:updated, %Order{}}
* {:deleted, %Order{}}
"""
def subscribe_orders(%Scope{} = scope) do
key = scope.user.id
Phoenix.PubSub.subscribe(Hello.PubSub, "user:#{key}:orders")
end
defp broadcast_order(%Scope{} = scope, message) do
key = scope.user.id
Phoenix.PubSub.broadcast(Hello.PubSub, "user:#{key}:orders", message)
end
@doc """
Returns the list of orders.
## Examples
iex> list_orders(scope)
[%Order{}, ...]
"""
def list_orders(%Scope{} = scope) do
Repo.all_by(Order, user_id: scope.user.id)
end
@doc """
Gets a single order.
Raises `Ecto.NoResultsError` if the Order does not exist.
## Examples
iex> get_order!(scope, 123)
%Order{}
iex> get_order!(scope, 456)
** (Ecto.NoResultsError)
"""
def get_order!(%Scope{} = scope, id) do
Repo.get_by!(Order, id: id, user_id: scope.user.id)
end
@doc """
Creates a order.
## Examples
iex> create_order(scope, %{field: value})
{:ok, %Order{}}
iex> create_order(scope, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_order(%Scope{} = scope, attrs) do
with {:ok, order = %Order{}} <-
%Order{}
|> Order.changeset(attrs, scope)
|> Repo.insert() do
broadcast_order(scope, {:created, order})
{:ok, order}
end
end
@doc """
Updates a order.
## Examples
iex> update_order(scope, order, %{field: new_value})
{:ok, %Order{}}
iex> update_order(scope, order, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_order(%Scope{} = scope, %Order{} = order, attrs) do
true = order.user_id == scope.user.id
with {:ok, order = %Order{}} <-
order
|> Order.changeset(attrs, scope)
|> Repo.update() do
broadcast_order(scope, {:updated, order})
{:ok, order}
end
end
@doc """
Deletes a order.
## Examples
iex> delete_order(scope, order)
{:ok, %Order{}}
iex> delete_order(scope, order)
{:error, %Ecto.Changeset{}}
"""
def delete_order(%Scope{} = scope, %Order{} = order) do
true = order.user_id == scope.user.id
with {:ok, order = %Order{}} <-
Repo.delete(order) do
broadcast_order(scope, {:deleted, order})
{:ok, order}
end
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking order changes.
## Examples
iex> change_order(scope, order)
%Ecto.Changeset{data: %Order{}}
"""
def change_order(%Scope{} = scope, %Order{} = order, attrs \\ %{}) do
true = order.user_id == scope.user.id
Order.changeset(order, attrs, scope)
end
alias Hello.Orders.LineItem
@doc """
Returns the list of order_line_items.
## Examples
iex> list_order_line_items()
[%LineItem{}, ...]
"""
def list_order_line_items do
Repo.all(LineItem)
end
@doc """
Gets a single line_item.
Raises `Ecto.NoResultsError` if the Line item does not exist.
## Examples
iex> get_line_item!(123)
%LineItem{}
iex> get_line_item!(456)
** (Ecto.NoResultsError)
"""
def get_line_item!(id), do: Repo.get!(LineItem, id)
@doc """
Creates a line_item.
## Examples
iex> create_line_item(%{field: value})
{:ok, %LineItem{}}
iex> create_line_item(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_line_item(attrs) do
%LineItem{}
|> LineItem.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a line_item.
## Examples
iex> update_line_item(line_item, %{field: new_value})
{:ok, %LineItem{}}
iex> update_line_item(line_item, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_line_item(%LineItem{} = line_item, attrs) do
line_item
|> LineItem.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a line_item.
## Examples
iex> delete_line_item(line_item)
{:ok, %LineItem{}}
iex> delete_line_item(line_item)
{:error, %Ecto.Changeset{}}
"""
def delete_line_item(%LineItem{} = line_item) do
Repo.delete(line_item)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking line_item changes.
## Examples
iex> change_line_item(line_item)
%Ecto.Changeset{data: %LineItem{}}
"""
def change_line_item(%LineItem{} = line_item, attrs \\ %{}) do
LineItem.changeset(line_item, attrs)
end
end
Repare em como o escopo atravessa o arquivo:
list_orders/1usaRepo.all_by(Order, user_id: scope.user.id), eget_order!/2usaRepo.get_by!(Order, id: id, user_id: scope.user.id): o dono faz parte da consulta;update_order/3,delete_order/2echange_order/3começam comtrue = order.user_id == scope.user.id. Se alguém passar o pedido de outra pessoa, o casamento falha comMatchError, de propósito: é um erro de programação, não um caso a tratar;subscribe_orders/1ebroadcast_order/2usam oPhoenix.PubSubcom um tópico por usuário,"user:ID:orders". Quem se inscreve (no módulo de LiveView, uma página aberta no navegador) recebe{:created, order},{:updated, order}ou{:deleted, order}quando os pedidos daquele usuário mudam;- as funções do
LineItem, geradas com--no-scope, não recebem escopo.
Para completar um pedido, o guia não usa o create_order/2 gerado:
Trilha.pergunta(:nome_da_funcao)
Completar um pedido, segundo o guia, exige três operações:
- gravar um pedido com o preço total;
- transformar cada item do carrinho em um item do pedido, com a quantidade e o preço do produto naquele momento;
- depois que o pedido for gravado (e, um dia, pago), esvaziar o carrinho.
A função do guia, em lib/hello/orders.ex:
alias Hello.Orders.LineItem
alias Hello.ShoppingCart
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
true = cart.user_id == scope.user.id
line_items =
Enum.map(cart.items, fn item ->
%{
product_id: item.product_id,
price: item.product.price,
quantity: item.quantity
}
end)
order_changeset =
Ecto.Changeset.change(%Order{}, %{
user_id: scope.user.id,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
})
Repo.transact(fn ->
with {:ok, order} <- Repo.insert(order_changeset),
{:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
{:ok, order}
end
end)
|> case do
{:ok, order} ->
broadcast_order(scope, {:created, order})
{:ok, order}
{:error, reason} ->
{:error, reason}
end
end
Passo a passo:
cart.itemsvem com os produtos carregados (oget_cart/1do notebook 10 faz os joins). Cada item vira um mapa comproduct_id,price(o preço do produto agora) equantity;Ecto.Changeset.change/2monta um changeset semcaste sem validações. Os dados não vêm de um formulário, e sim do próprio sistema. A chaveline_itemsrecebe a lista de mapas: comoOrdertemhas_many :line_items, o Ecto entende cada mapa como um item novo e o insere junto com o pedido;- o total vem de
ShoppingCart.total_cart_price/1. Um contexto pode chamar outro e trabalhar com dados de outro (aqui, o carrinho); Repo.transact/2executa as duas etapas numa transação: se a segunda falhar, a primeira é desfeita. A seção seguinte mostra isso no SQL;- no fim,
broadcast_order/2avisa os inscritos no tópico do usuário.
Sua vez: completar o pedido
Três mudanças:
- Na célula do
Hello.ShoppingCart(seção "A aplicação Hello"), escrevaprune_cart_items(scope, cart): apague todos os itens do carrinho com um sóRepo.delete_all/1e devolva{:ok, get_cart(scope)}, o carrinho recarregado. Avalie a célula. - Na célula do
Hello.Orders, façaget_order!/2carregar os itens e os produtos de cada item, para a página do pedido: acrescente|> Repo.preload(line_items: [:product])depois doRepo.get_by!. - Na mesma célula, ponha a função
complete_order/2do guia logo depois da linhaalias Hello.Orders.LineItem, com oalias Hello.ShoppingCart. Avalie a célula.
Se a célula do Orders for avaliada antes da do ShoppingCart, aparece o aviso Hello.ShoppingCart.prune_cart_items/2 is undefined or private: a função ainda não existe. Avalie as duas na ordem.
A conferência cria um usuário de teste com um carrinho de verdade (dois livros e um baralho), completa o pedido, olha as tabelas e depois apaga o que criou.
Trilha.conferir(:complete_order)
Trilha.solucao(:complete_order)
Erro como conteúdo: uma transação desfeita
Trilha.pergunta(:transacao)
A célula abaixo imita a estrutura de complete_order/2 com uma segunda etapa que falha, como um pagamento recusado. Leia o log: o INSERT do pedido acontece, entre um begin e um rollback.
bia = Hello.Accounts.get_user_by_email("bia@example.com")
Hello.Repo.transact(fn ->
{:ok, pedido} =
%Hello.Orders.Order{}
|> Ecto.Changeset.change(user_id: bia.id, total_price: Decimal.new("35"))
|> Hello.Repo.insert()
IO.puts("Dentro da transação, o pedido #{pedido.id} existe.")
{:error, :pagamento_recusado}
end)
O resultado é {:error, :pagamento_recusado}, o mesmo valor que a função devolveu. E o pedido que existia dentro da transação:
Trilha.consultar("SELECT id, user_id, total_price FROM orders")
A tabela não tem pedido nenhum. O id usado lá dentro, porém, não volta: a sequência do PostgreSQL que gera os ids não participa da transação. Por isso o próximo pedido gravado não vai ter o id 1, e é normal haver buracos na numeração.
A web: as rotas, o controller e a página do pedido
Na documentação: Bringing It Home, os trechos sobre o router e o OrderController. O guia acrescenta as rotas antes do migrate; aqui elas vêm junto do controller, que é quem precisa delas.
O guia acrescenta as rotas dos pedidos ao scope que exige login. Só duas actions são necessárias: create, que completa o pedido, e show, que o mostra.
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
+ resources "/orders", OrderController, only: [:create, :show]
end
lib/hello_web/router.ex, com as rotas dos pedidos. A célula repete o router inteiro, porque um módulo não pode ser alterado aos pedaços; ela fica aqui, antes do controller, para que o ~p"/orders/#{order}" do controller encontre a rota. O aviso HelloWeb.OrderController.init/1 is undefined é esperado.
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
# Brechó (notebook 08)
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
resources "/orders", OrderController, only: [:create, :show]
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/order_controller.ex, com a action create do guia. O conn.assigns.cart vem do plug fetch_current_cart do router. Se complete_order/2 devolve {:ok, order}, o usuário vai para a página do pedido; qualquer {:error, _} o devolve ao carrinho com uma flash de erro. Se o exercício "Completar o pedido" ainda não foi feito, avaliar esta célula avisa Hello.Orders.complete_order/2 is undefined or private.
defmodule HelloWeb.OrderController do
use HelloWeb, :controller
alias Hello.Orders
def create(conn, _) do
case Orders.complete_order(conn.assigns.current_scope, conn.assigns.cart) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created successfully.")
|> redirect(to: ~p"/orders/#{order}")
{:error, _reason} ->
conn
|> put_flash(:error, "There was an error processing your order")
|> redirect(to: ~p"/cart")
end
end
end
Erro como conteúdo: o pedido existe, a página não
O controller ainda não tem show. Mesmo assim, a Bia já pode completar o pedido:
Trilha.navegar("bia", :post, "/orders")
O primeiro passo deu certo: 302 para /orders/ seguido do id do pedido novo, com a flash "Order created successfully.". O pedido foi gravado, e o carrinho da Bia foi esvaziado. (O id não é 1: um se perdeu no rollback da seção anterior, e as conferências também gastam ids.) O segundo passo, o GET do redirect, levanta UndefinedFunctionError: function HelloWeb.OrderController.show/2 is undefined or private. O router achou a rota, e o controller não tem a action.
Se a mensagem fala de Hello.Orders.complete_order/2, o exercício "Completar o pedido" ainda não foi feito; volte a ele. E se você já fez o exercício abaixo e está avaliando o notebook de novo desde o começo, o erro é outro: no "show" html template defined for HelloWeb.OrderHTML (the module does not exist). A action existe, mas a célula da view vem depois desta.
Sua vez: a página do pedido
Três mudanças, todas com o código do guia:
-
Na célula do
OrderController, a actionshow:def show(conn, %{"id" => id}) do order = Orders.get_order!(conn.assigns.current_scope, id) render(conn, :show, order: order) endO escopo garante que cada usuário só vê os próprios pedidos.
-
Na célula abaixo, o módulo
HelloWeb.OrderHTML, com uma funçãoshow/1que contém o template do guia (order_html/show.html.heex):<Layouts.app flash={@flash}> <.header> Thank you for your order! <:subtitle> <strong>Email: </strong>{@current_scope.user.email} </:subtitle> </.header> <.table id="items" rows={@order.line_items}> <:col :let={item} label="Title">{item.product.title}</:col> <:col :let={item} label="Quantity">{item.quantity}</:col> <:col :let={item} label="Price"> {HelloWeb.CartHTML.currency_to_str(item.price)} </:col> </.table> <strong>Total price:</strong> {HelloWeb.CartHTML.currency_to_str(@order.total_price)} <.button navigate={~p"/products"}>Back to products</.button> </Layouts.app>O template reaproveita
currency_to_str/1doCartHTML. O preço de cada linha éitem.price, o preço gravado no item, e nãoitem.product.price. -
Na cópia do
CartHTML, duas células abaixo, o botão que completa o pedido, no slot:actionsdo<.header>:<.header> My Cart <:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle> <:actions> <.button href={~p"/orders"} method="post"> Complete order </.button> </:actions> </.header>Com
hrefemethod="post", o<.button>vira um link que ophoenix_html.jstransforma em umPOSTcom o token CSRF.
lib/hello_web/controllers/order_html.ex
# lib/hello_web/controllers/order_html.ex
lib/hello_web/controllers/cart_html.ex: o CartHTML da seção "A aplicação Hello", repetido aqui. O botão novo usa ~p"/orders", e esta célula vem depois do router que tem essa rota; editada lá em cima, a célula compilaria antes do router novo sempre que o notebook fosse avaliado do começo, com um aviso no route path. Edite esta cópia; a de cima fica como está.
# O botão Complete order vai no slot :actions do <.header> abaixo.
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
Trilha.conferir(:pagina_do_pedido)
Trilha.solucao(:pagina_do_pedido)
O pedido completo
Com a página pronta, a Bia faz uma compra do começo ao fim. Antes, esta célula inscreve o processo do notebook no tópico de pedidos da Bia, como uma LiveView faria, para ver o aviso do broadcast_order/2.
scope_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))
Hello.Orders.subscribe_orders(scope_da_bia)
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=1")
Trilha.visitar("bia", :post, "/cart_items?product_id=2")
Trilha.navegar("bia", :get, "/cart")
O carrinho tem o botão "Complete order". Ela clica:
Trilha.navegar("bia", :post, "/orders")
O guia mostra uma linha "User uuid" nesta página, de uma versão antiga; o template do guia, o mesmo usado aqui, mostra o e-mail. Os preços das linhas são os do momento da compra, e o total é $35.00.
Enquanto isso, o aviso do PubSub chegou à caixa de mensagens do processo do notebook:
receive do
{:created, pedido} -> "Chegou {:created, %Order{id: #{pedido.id}}} no tópico \"user:#{scope_da_bia.user.id}:orders\"."
after
1000 -> "Nenhuma mensagem chegou."
end
O preço do momento
Trilha.pergunta(:preco_no_pedido)
A loja sobe o preço do livro para $20 pela API do catálogo, e a Bia abre de novo o pedido:
livro = Hello.Catalog.get_product!(1)
{:ok, _} = Hello.Catalog.update_product(livro, %{price: "20.00"})
Trilha.navegar("bia", :get, "/orders/#{Trilha.ultimo_pedido("bia@example.com")}")
O pedido continua com $15.00 na linha do livro e $35.00 no total. A página do produto já mostra o preço novo:
Trilha.navegar("bia", :get, "/products/1")
O carrinho se comporta de outro jeito. ShoppingCart.total_item_price/1 multiplica item.product.price, o preço atual, e não o price_when_carted que o notebook 10 guardou ao pôr o item no carrinho. O guia guarda esse preço "para detectar mudanças no futuro", mas não o usa ainda. Quem fecha a conta é complete_order/2, com o preço da hora do pagamento.
Erro como conteúdo: o pedido de outra pessoa
Trilha.pergunta(:pedido_alheio)
O Caio entra e abre o endereço do último pedido da Bia:
Trilha.visitar("caio", :post, "/users/log-in", %{"user" => %{"email" => "caio@example.com"}})
Trilha.navegar("caio", :get, "/orders/#{Trilha.ultimo_pedido("bia@example.com")}")
A consulta, que aparece na mensagem, procura o pedido com aquele id e com o user_id do Caio. Não há tal pedido, e Repo.get_by!/2 levanta Ecto.NoResultsError. Aqui, com debug_errors: true, o Phoenix mostra a página de depuração. Em produção, o usuário vê a página de erro 404. Quem diz ao Plug que essa exceção vale 404 é o phoenix_ecto, que implementa o protocolo Plug.Exception para ela:
Hello.Orders.Order
|> then(&Ecto.NoResultsError.exception(queryable: &1))
|> Plug.Exception.status()
O mesmo pacote dá status a outras exceções do Ecto: 400 para Ecto.CastError e Ecto.Query.CastError (um id como "abc", por exemplo) e 409 para Ecto.StaleEntryError.
Erro como conteúdo: o pedido vazio
Trilha.pergunta(:carrinho_vazio)
O carrinho da Bia ficou vazio depois do pedido. Ela clica em "Complete order" de novo:
Trilha.navegar("bia", :post, "/orders")
Um pedido de $0.00, sem nenhuma linha, foi gravado. (Se você já fez o exercício abaixo, a resposta é outra: a volta ao carrinho com a flash de erro.)
Sua vez: um pedido vazio
"Não existe pedido sem itens" é uma regra de negócio, e o lugar dela é o contexto que cuida de pedidos. Na célula do Hello.Orders, acrescente uma cláusula de complete_order/2, antes da que já existe, que case com um carrinho sem itens (%ShoppingCart.Cart{items: []}) e devolva {:error, :carrinho_vazio}. O OrderController não muda: ele já trata qualquer {:error, _}.
Trilha.conferir(:carrinho_vazio)
Trilha.solucao(:carrinho_vazio)
Com a regra no lugar, o mesmo clique da Bia volta para o carrinho:
Trilha.navegar("bia", :post, "/orders")
FAQ
Na documentação: FAQ
O último guia do bloco de modelagem de dados responde a três perguntas frequentes sobre contextos.
Quando usar os geradores?
Na documentação: When to use code generators?
Os guias usaram geradores para schemas, contextos e controllers. Se os padrões do Phoenix servem para você, pode contar com eles para boa parte da aplicação. A pergunta que o gerador obriga a responder é a mais importante: esta funcionalidade, com o seu schema, a sua tabela e os seus campos, pertence a um dos contextos que já existem ou a um novo? Foi o que este módulo fez a cada guia: produtos no Catalog, categorias também, carrinho em um contexto novo, pedidos em outro. Quando o nome do contexto não vem, use o plural do recurso (Products, Orders) e renomeie depois.
Como organizar o código dentro de um contexto?
Na documentação: How do I structure code inside contexts?
Trilha.pergunta(:faq_estrutura)
O contexto é a API pública; os módulos dentro dele são privados. Isso torna a organização interna uma decisão pequena. O Orders deste notebook tem as consultas no próprio orders.ex e os changesets nos schemas, como o gerador fez. Se um dia ele crescer, as consultas podem ir para um Orders.Queries, e nenhum controller vai notar.
Devolver estruturas do Ecto pela API do contexto
Na documentação: Returning Ecto structures from context APIs
Se o contexto existe para esconder o Repo, por que create_product/1 devolve um Ecto.Changeset quando falha? Porque o changeset não depende de banco. Ele acompanha mudanças, valida e guarda mensagens de erro para qualquer tipo de dado, venha ele de uma tabela, de uma API externa ou de um formulário que nem será gravado. Um changeset sem schema e sem tabela, só com tipos:
tipos = %{email: :string, mensagem: :string}
contato =
{%{}, tipos}
|> Ecto.Changeset.cast(%{"email" => "bia@example.com", "mensagem" => ""}, Map.keys(tipos))
|> Ecto.Changeset.validate_required([:email, :mensagem])
|> Ecto.Changeset.validate_length(:mensagem, max: 500)
{contato.valid?, contato.errors}
O mesmo changeset vira um formulário. O Phoenix não conhece o Ecto: ele define protocolos, e cada tipo de dado diz como se comporta. to_form/2 aceita um mapa simples ou um changeset; o campo impl mostra qual implementação do protocolo Phoenix.HTML.FormData foi usada:
{
Phoenix.Component.to_form(%{"email" => "bia@example.com"}, as: :contato).impl,
Phoenix.Component.to_form(contato, as: :contato).impl
}
A implementação para changesets, Phoenix.HTML.FormData.Ecto.Changeset, vem do pacote phoenix_ecto. É ela que leva os erros do changeset para os <.input>.
O outro protocolo citado pelo guia é Phoenix.Param, que transforma um valor no trecho de uma URL. É ele que faz ~p"/products/#{product}" virar /products/1:
Phoenix.Param.impl_for(Hello.Catalog.get_product!(1))
O guia diz que o phoenix_ecto implementa esse protocolo, mas no Phoenix 1.8.16 a implementação usada pelos schemas é a do próprio Phoenix, Phoenix.Param.Any: qualquer struct com o campo id serve. O phoenix_ecto implementa Phoenix.HTML.FormData (para changesets), Plug.Exception (para as exceções do Ecto, como visto acima) e Phoenix.HTML.Safe para Decimal, que é o que permite escrever {@product.price} num template.
Como o protocolo é aberto, uma struct que nunca passou pelo Ecto também pode ir para uma URL. Um livro identificado pelo ISBN:
defmodule Exemplo.Livro do
@derive {Phoenix.Param, key: :isbn}
defstruct [:isbn, :titulo]
end
defmodule Exemplo.Links do
use HelloWeb, :verified_routes
def produto(algo), do: ~p"/products/#{algo}"
end
A struct não pode ser usada na mesma célula que a define (o Elixir expande a célula inteira antes de executá-la), então o uso vem na célula seguinte:
{
Exemplo.Links.produto(Hello.Catalog.get_product!(1)),
Exemplo.Links.produto(%Exemplo.Livro{isbn: "978-1-68050-041-7", titulo: "Metaprogramming Elixir"})
}
@derive {Phoenix.Param, key: :isbn} gera a implementação do protocolo usando o campo isbn. Num projeto, os protocolos são consolidados na compilação e isso funciona do mesmo jeito; no notebook, a instalação desliga a consolidação (consolidate_protocols: false) para que implementações novas valham na hora.
Sem roteiro: as compras do Brechó
Agora o mesmo assunto sem passo a passo. O Brechó, o mini-marketplace da trilha, vende peças únicas: cada peça existe uma vez só. No notebook 08, a vitrine virou o contexto Hello.Vitrine, com uma venda atômica de uma peça. Agora uma pessoa logada compra várias peças de uma vez, e a compra precisa ser tudo ou nada.
O Brechó até aqui
As células abaixo são a solução do desafio do notebook 08: a tabela produtos (do notebook 07), o contexto Hello.Vitrine e as páginas da vitrine. Avalie-as.
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/controllers/produto_controller.ex e produto_html.ex: as rotas já estão no router.
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
Crie o contexto Hello.Compras:
- Uma migration, versão
20250323000000, com a tabelacompras:user_id(referência ausers, comon_delete: :delete_allenull: false),total_centavos(inteiro,null: false) e os timestamps. Como cada compra se liga às suas peças fica a seu critério: uma tabela de itens, comoorder_line_items, ou uma colunacompra_idemprodutos. Escolha e saiba dizer por quê. (A solução usa uma tabelacompra_itenscom o preço de cada peça, pela mesma razão do guia: o preço da compra não muda se a vitrine mudar. E um índice único emcompra_itens.produto_idgarante, no banco, que uma peça só entra em uma compra.) - A função
Compras.comprar(scope, produto_ids), com escopo, como o guia fez comOrders. Dentro de umRepo.transact/2:- marque todas as peças pedidas como vendidas com um só
Repo.update_all/3, que só pegue as que ainda não foram vendidas (where: not p.vendido), como oVitrine.vender/1; - se o número de peças atualizadas for diferente do número de peças pedidas, alguma já tinha sido vendida: devolva
{:error, :indisponivel}, e otransactdesfaz tudo; - senão, grave a compra do usuário do escopo com o total e devolva
{:ok, compra}.
- marque todas as peças pedidas como vendidas com um só
O update_all com select: p devolve as peças atualizadas, com os preços: é de lá que sai o total.
# priv/repo/migrations/20250323000000_create_compras.exs
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/compras.ex (o contexto) e os schemas
Para experimentar: a Bia compra a jaqueta e o vestido, e depois tenta o vestido e a bota. Antes de você escrever o contexto, esta célula dá UndefinedFunctionError.
scope_da_bia = Hello.Accounts.Scope.for_user(Hello.Accounts.get_user_by_email("bia@example.com"))
{
Hello.Compras.comprar(scope_da_bia, [1, 2]),
Hello.Compras.comprar(scope_da_bia, [2, 3])
}
A primeira compra dá certo, com o total de 12500 centavos. A segunda devolve {:error, :indisponivel}, porque o vestido já foi vendido, e a bota continua à venda.
A conferência compra duas peças, tenta uma compra com uma peça vendida e põe seis pessoas para disputar a mesma peça ao mesmo tempo. Ela cria e apaga as próprias peças e os próprios usuários.
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Repita o guia no projeto em que você fez os notebooks 08 a 10. A correspondência entre o notebook e o projeto:
| Neste notebook | No projeto |
|---|---|
Células das migrations + Trilha.migracao/2 |
priv/repo/migrations/*_create_orders.exs e *_create_order_line_items.exs, criadas pelo gerador |
Ecto.Migrator.run(..., :up, all: true) |
mix ecto.migrate |
Ecto.Migrator.run(..., :down, to: VERSAO) |
mix ecto.rollback --to VERSAO |
Célula do Hello.Orders |
lib/hello/orders.ex |
| Células dos schemas | lib/hello/orders/order.ex e lib/hello/orders/line_item.ex |
Célula do OrderHTML com show/1 |
lib/hello_web/controllers/order_html.ex com embed_templates "order_html/*" e o arquivo order_html/show.html.heex |
| Autenticação reduzida | A gerada por mix phx.gen.auth Accounts User users, com login por e-mail e a caixa de correio em /dev/mailbox |
Trilha.navegar("bia", ...) |
O navegador, logado com a sua conta |
Trilha.recomecar() |
mix ecto.reset |
Roteiro:
- Rode
mix phx.gen.context Orders Order orders total_price:decimale leia oconfig/config.exspara achar o escopo padrão que o gerador usou. - Rode
mix phx.gen.context Orders LineItem order_line_items price:decimal quantity:integer order_id:references:orders product_id:references:products --no-scopee responday. - Ajuste as duas migrations (decimal com
precision: 15, scale: 6, null: false) e só então rodemix ecto.migrate. - Troque os campos
*_idpelas associações e, noiex -S mix, carregue os produtos de um pedido comHello.Repo.preload(pedido, :products). - Escreva
complete_order/2,prune_cart_items/2e o preload emget_order!/2; acrescente as rotas, oOrderController, oOrderHTMLe o botão no carrinho. - Faça um pedido pelo navegador. Depois, abra o endereço dele com outra conta e veja a página de depuração do
Ecto.NoResultsError. - Rode
mix test test/hello/orders_test.exs. Os testes gerados usam fixtures com escopo (user_scope_fixture/0) e mostram como o gerador verifica que um usuário não enxerga os dados de outro (other_scope). Espere pelo menos uma falha: o teste deget_order!/2compara o pedido da fixture, comline_itemsnão carregados, com o pedido que a função devolve, agora com o preload. Os testes gerados descrevem o código gerado; quando o código muda, eles mudam junto. Ajuste-o, por exemplo comparando osid.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que acontece, passo a passo, entre o clique em "Complete order" e a página "Thank you for your order!". Cite o router, o plug do carrinho, o controller, o contexto, a transação e o template. Depois compare com os pontos abaixo.
- Completar um pedido é uma operação de negócio, com nome próprio (
complete_order/2) num contexto próprio (Orders). O controller só traduz a requisição em uma chamada ao contexto e a resposta em um redirect. - O escopo padrão, configurado pelo
phx.gen.auth, fez o gerador pôruser_idno schema,scopeem todas as funções e o dono dentro de cada consulta. Um usuário não acha os pedidos de outro: para ele, eles não existem (Ecto.NoResultsError, 404). has_many :products, through: [:line_items, :product]é um caminho entre associações, sem tabela nova.Ecto.Changeset.change/2monta um changeset semcastpara dados que vêm do próprio sistema; uma lista de mapas numa associaçãohas_manyvira registros filhos inseridos junto.Repo.transact/2confirma tudo quando a função devolve{:ok, _}e desfaz tudo quando ela devolve{:error, _}. As sequências de ids não voltam atrás.- O item do pedido copia o preço do momento. O pedido não muda quando o catálogo muda.
- Regras de negócio, como "não existe pedido vazio", moram no contexto; o banco e os changesets cuidam do formato dos dados.
- Changesets não dependem do banco. O Phoenix conversa com eles e com qualquer outra estrutura por protocolos:
Phoenix.HTML.FormData, para formulários, ePhoenix.Param, para URLs.
O módulo 6 termina aqui, com quatro contextos, cada um dono de uma parte dos dados:
| Contexto | Cuida de | Tabelas |
|---|---|---|
Catalog |
A exibição dos produtos: dados, categorias, contagem de visitas | products, categories, product_categories |
Accounts |
Quem é o usuário, e o Scope que viaja com ele |
users (e users_tokens, no gerado) |
ShoppingCart |
O carrinho de cada usuário e as quantidades | carts, cart_items |
Orders |
Os pedidos completados, com o preço do momento | orders, order_line_items |
Os contextos se chamam (ShoppingCart usa Catalog.get_product!/1; Orders usa ShoppingCart.total_cart_price/1) e os schemas se ligam por chaves estrangeiras, mas cada um tem a sua API e as suas regras.
Próximo guia da trilha: JSON and APIs, que expõe esses mesmos contextos por uma API JSON.