Trilha Phoenix · Relacionamentos no contexto
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 "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
# --- navegador ---------------------------------------------------------------
@doc """
Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
`quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
"""
def visitar(quem, metodo, caminho, parametros \\ nil) do
quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
end
@doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
passos = seguir(quem, metodo, caminho, parametros, [])
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)
cabecalho = [
"### #{quem}: #{rotulo(metodo, caminho)}",
"",
"| # | Requisição | Status | Redireciona para |",
"| --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
end
@doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
def esquecer_visitantes do
for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
:ok
end
defp seguir(quem, metodo, caminho, parametros, feitos) do
conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
resultado = requisicao(metodo, caminho, parametros, conn)
feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]
with {:ok, resp} <- resultado do
guardar_cookies(quem, resp)
end
with {:ok, resp} <- resultado,
true <- resp.status in 300..399 and length(feitos) < 6,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(quem, :get, destino, nil, feitos)
else
_ -> feitos
end
end
defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})
defp guardar_cookies(quem, resp) do
novos =
Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
end)
:persistent_term.put({__MODULE__, :cookies, quem}, novos)
end
defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"
defp linha_passo(i, requisicao, {:ok, conn}) do
destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
end
defp linha_passo(i, requisicao, {:erro, erro}) do
"| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
end
defp resumo_final({_requisicao, {:ok, conn}}) do
flash =
case conn.assigns[:flash] do
mapa when is_map(mapa) and map_size(mapa) > 0 ->
"**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"
_ ->
""
end
flash <> "**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:
"um produto pode ter categorias, e uma categoria agrupa produtos: as duas tabelas vão se ligar no banco. Isso basta para decidir que `Category` mora no contexto `Catalog`?",
opcoes: [
a: "Sim: recursos ligados por chave estrangeira devem ficar no mesmo contexto.",
b: "Não: estar ligado no banco não decide nada. `Category` vai para o `Catalog` porque classificar produtos faz parte de exibi-los.",
c: "Não: cada schema precisa do seu próprio contexto, sempre.",
d: "Sim: o `phx.gen.context` recusa gerar um recurso relacionado em outro contexto."
],
correta: :b,
explicacao:
"Quase tudo em uma aplicação se liga a alguma outra coisa; se cada ligação puxasse os recursos para o mesmo contexto, acabaria tudo num contexto só. O critério é a responsabilidade: o `Catalog` cuida da exibição dos produtos, e categorizar é parte disso. O próprio gerador avisa, ao gerar em um contexto existente, que duas entidades relacionadas no banco não pertencem por isso ao mesmo contexto."
},
gen_context: %{
texto:
"o `phx.gen.html` do notebook 08 criou contexto, schema, migration, controller, templates e testes. O que o `mix phx.gen.context Catalog Category categories title:string:unique` deixa de criar?",
opcoes: [
a: "A migration: o `phx.gen.context` só mexe no código Elixir.",
b: "As funções do contexto: ele cria só o schema.",
c: "A parte web: controller, view, templates e o teste do controller.",
d: "Nada: os dois geradores criam os mesmos arquivos."
],
correta: :c,
explicacao:
"O `phx.gen.context` é o `phx.gen.html` sem a camada web: gera o schema, a migration, as funções no contexto e os testes do contexto. Serve quando a interface já existe ou vai ser outra, como aqui, em que as categorias entram no formulário de produtos que já existe."
},
chave_primaria: %{
texto:
"a tabela `product_categories` só liga um produto a uma categoria. A migration a cria com `primary_key: false`. O que essa opção muda?",
opcoes: [
a: "A tabela fica sem a coluna `id`: cada linha é identificada pelo par `product_id`, `category_id`, e ninguém precisa de um número próprio para ela.",
b: "A tabela aceita linhas repetidas, porque não há chave.",
c: "As colunas `product_id` e `category_id` deixam de aceitar `NULL`.",
d: "O Ecto passa a gerar o `id` no lugar do PostgreSQL."
],
correta: :a,
explicacao:
"Sem a opção, `create table` acrescenta uma coluna `id` com sequência, como em toda tabela até aqui. Uma linha de junção não é uma entidade com vida própria: ninguém busca \"a ligação 37\". O que importa é o par de chaves estrangeiras, e o índice único sobre esse par impede as repetições."
},
indice_redundante: %{
texto:
"a migration cria um índice em `product_id` e um índice único em `[:category_id, :product_id]`, mas nenhum índice só em `category_id`. Uma busca `WHERE category_id = 4` fica sem índice?",
opcoes: [
a: "Fica: cada coluna consultada precisa do seu próprio índice.",
b: "Não: o índice de duas colunas começa por `category_id`, e o PostgreSQL usa esse prefixo à esquerda para buscar só por `category_id`.",
c: "Não: chaves estrangeiras ganham índice automático no PostgreSQL.",
d: "Fica, mas o `many_to_many` do Ecto não consulta por `category_id`."
],
correta: :b,
explicacao:
"Um índice de várias colunas é ordenado pela primeira, depois pela segunda. Ele serve para buscas pela primeira coluna sozinha (o prefixo à esquerda), mas não pela segunda sozinha; por isso `product_id` ganhou índice próprio. Um índice só em `category_id` seria redundante e custaria tempo a cada escrita. E o PostgreSQL, ao contrário do MySQL, não cria índice para chave estrangeira por conta própria."
},
on_delete: %{
texto:
"as duas referências da tabela de junção usam `on_delete: :delete_all`. O que acontece com as linhas de `product_categories` quando uma categoria é apagada?",
opcoes: [
a: "Ficam no banco, apontando para uma categoria que não existe mais.",
b: "O PostgreSQL recusa apagar a categoria enquanto houver produtos nela.",
c: "O PostgreSQL apaga junto as linhas que apontam para ela, sem código nenhum no contexto.",
d: "O Ecto apaga as linhas antes, se o schema tiver `many_to_many`."
],
correta: :c,
explicacao:
"`on_delete: :delete_all` vira `ON DELETE CASCADE` na chave estrangeira: quem apaga é o banco, na mesma operação. O padrão do gerador, `on_delete: :nothing`, faria o PostgreSQL recusar o `DELETE` da categoria com um erro de chave estrangeira. Deixar a regra no banco garante a integridade mesmo para quem apaga sem passar pelo contexto."
},
on_replace: %{
texto:
"o produto tem as categorias Books e Education, carregadas. O código troca a lista por `[Gardening]` com `put_assoc`. Com `on_replace: :delete` na associação, o que acontece com Books e Education ao gravar?",
opcoes: [
a: "As categorias Books e Education são apagadas da tabela `categories`.",
b: "As linhas de junção que ligam o produto a Books e a Education são apagadas; as categorias continuam existindo.",
c: "Nada: o produto passa a ter as três categorias.",
d: "O Ecto levanta um erro, porque a lista antiga não pode ser substituída."
],
correta: :b,
explicacao:
"Em um `many_to_many`, o que pertence ao produto é a ligação, não a categoria. `on_replace: :delete` diz ao Ecto que, quando um item sai da lista, a ligação deve ser apagada. Sem a opção, o padrão é `:raise`: o Ecto recusa a troca com um erro dizendo que a relação não pode ser substituída."
},
sem_category_ids: %{
texto:
"com o `change_product/2` do guia, o produto tem as categorias Books e Education. Outro trecho do código chama `Catalog.update_product(product, %{\"title\" => \"Novo título\"})`, sem `\"category_ids\"`. Com que categorias o produto fica?",
opcoes: [
a: "Books e Education: só o título mudou.",
b: "Nenhuma: sem `\"category_ids\"`, `list_categories_by_id(nil)` devolve `[]`, e o `put_assoc` troca as categorias por uma lista vazia.",
c: "O Ecto levanta um erro, porque `\"category_ids\"` é obrigatório.",
d: "Depende de o produto ter sido carregado com `preload`."
],
correta: :b,
explicacao:
"O `change_product/2` do guia chama `put_assoc` sempre. Quando os parâmetros não trazem `\"category_ids\"`, a lista posta é vazia, e com `on_replace: :delete` as ligações antigas são apagadas. Para o formulário isso é o que se quer, porque um `<select multiple>` sem nada marcado não envia o campo. Para qualquer outro chamador, é uma armadilha. A seção \"Para ir além\" volta a isso."
},
struct_velha: %{
texto:
"você avaliou de novo as células do `Product` (com `many_to_many`) e do `Catalog`. A célula do `ProductController`, lá no começo, não foi avaliada de novo. Por que ela ainda pode estar desatualizada, se só chama funções do `Catalog`?",
opcoes: [
a: "Não pode: chamadas a outros módulos são resolvidas na hora da execução.",
b: "Porque o router guarda uma cópia do controller.",
c: "Porque `%Product{}` em `new/2` é expandido quando o controller compila: o código compilado guarda os campos que a struct tinha naquele momento, sem `categories`.",
d: "Porque o Ecto guarda o schema antigo em cache até a aplicação reiniciar."
],
correta: :c,
explicacao:
"Chamadas como `Catalog.change_product/2` são resolvidas na execução e veem o módulo novo. Uma struct literal não: o compilador a transforma em um mapa com os campos conhecidos na compilação. Em um projeto, o `mix` sabe que o controller depende da struct `Product` em tempo de compilação e o recompila sozinho. No notebook, quem recompila é você, avaliando a célula de novo."
},
formulario_mente: %{
texto:
"o produto tem Books e Education. Na edição, Ana marca só Home Improvement e apaga o título sem querer. O formulário volta com o erro `can't be blank`. Com o `category_opts/1` do guia, quais categorias aparecem marcadas?",
opcoes: [
a: "Só Home Improvement, a escolha de Ana.",
b: "Books e Education, as que estão gravadas.",
c: "Home Improvement, Books e Education.",
d: "Nenhuma, porque a gravação falhou."
],
correta: :c,
explicacao:
"`category_opts/1` pega as mudanças de `:categories` com `get_change/3`. Depois de um `put_assoc` com `on_replace: :delete`, essa lista tem um changeset para cada categoria nova (ação `:update`) e um para cada categoria que sai (ação `:replace`). O código usa `&1.data.id` de todos, sem olhar a ação, e marca os três. Se Ana corrigir o título e salvar sem reparar, o produto fica com as três categorias."
}
}
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
%{
juncao: ~S'''
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
create table(:product_categories, primary_key: false) do
add :product_id, references(:products, on_delete: :delete_all)
add :category_id, references(:categories, on_delete: :delete_all)
end
create index(:product_categories, [:product_id])
create unique_index(:product_categories, [:category_id, :product_id])
end
end
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)
# Depois: Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# Se a migration já tinha rodado vazia, antes dela:
# Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, step: 1)
''',
many_to_many: ~S'''
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
''',
change_product: ~S'''
# No Hello.Catalog, além do alias Hello.Catalog.Category no topo:
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 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
''',
formulario: ~S'''
# No HelloWeb.ProductHTML:
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
# Em show/1 (show.html.heex), dentro do <.list>, depois de Views:
#
# <:item title="Categories">
# <ul>
# <li :for={cat <- @product.categories}>{cat.title}</li>
# </ul>
# </:item>
#
# Em product_form/1 (product_form.html.heex), antes do <footer>:
#
# <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
''',
formulario_honesto: ~S'''
# No Hello.Catalog: put_assoc só quando o formulário mandou "category_ids",
# e sem as strings vazias que o campo escondido envia.
def change_product(%Product{} = product, attrs \\ %{}) do
changeset =
product
|> Repo.preload(:categories)
|> Product.changeset(attrs)
case attrs["category_ids"] do
nil -> changeset
category_ids -> Ecto.Changeset.put_assoc(changeset, :categories, list_categories_by_id(category_ids))
end
end
def list_categories_by_id(nil), do: []
def list_categories_by_id(category_ids) do
category_ids = Enum.reject(category_ids, &(&1 == ""))
Repo.all(from c in Category, where: c.id in ^category_ids)
end
# No HelloWeb.ProductHTML: as categorias que o produto TERÁ, com get_field.
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_field(:categories)
|> Enum.map(& &1.id)
for cat <- Hello.Catalog.list_categories() do
[key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
end
# Em product_form/1, logo antes do <.input ... type="select" multiple ...>:
#
# <input type="hidden" name="product[category_ids][]" value="" />
''',
brecho: ~S'''
# priv/repo/migrations/20250321000000_create_etiquetas.exs
defmodule Hello.Repo.Migrations.CreateEtiquetas do
use Ecto.Migration
def change do
create table(:etiquetas) do
add :nome, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:etiquetas, [:nome])
create table(:produto_etiquetas, primary_key: false) do
add :produto_id, references(:produtos, on_delete: :delete_all)
add :etiqueta_id, references(:etiquetas, on_delete: :delete_all)
end
create index(:produto_etiquetas, [:produto_id])
create unique_index(:produto_etiquetas, [:etiqueta_id, :produto_id])
end
end
Trilha.migracao(20250321000000, Hello.Repo.Migrations.CreateEtiquetas)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
# lib/hello/vitrine/etiqueta.ex e lib/hello/vitrine/produto.ex
defmodule Hello.Vitrine.Etiqueta do
use Ecto.Schema
import Ecto.Changeset
schema "etiquetas" do
field :nome, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(etiqueta, attrs) do
etiqueta
|> cast(attrs, [:nome])
|> validate_required([:nome])
|> unique_constraint(:nome)
end
end
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
many_to_many :etiquetas, Hello.Vitrine.Etiqueta, join_through: "produto_etiquetas", on_replace: :delete
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
# lib/hello/vitrine.ex: as funções da Vitrine de antes, mais estas duas
# (e alias Hello.Vitrine.Etiqueta no topo). Avalie depois do Produto,
# para que o %Produto{} de cadastrar_produto/1 tenha o campo etiquetas.
def etiquetar(%Produto{} = produto, nomes) do
nomes =
nomes
|> Enum.map(&(&1 |> String.trim() |> String.downcase()))
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
agora = DateTime.utc_now(:second)
linhas = Enum.map(nomes, &%{nome: &1, inserted_at: agora, updated_at: agora})
# Cria as que faltam; as que já existem são ignoradas, sem erro,
# mesmo que outro processo as tenha criado um instante antes.
Repo.insert_all(Etiqueta, linhas, on_conflict: :nothing, conflict_target: :nome)
etiquetas = Repo.all(from e in Etiqueta, where: e.nome in ^nomes)
produto
|> Repo.preload(:etiquetas)
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:etiquetas, etiquetas)
|> Repo.update()
end
def listar_por_etiqueta(nome) do
Repo.all(
from p in Produto,
join: e in assoc(p, :etiquetas),
where: e.nome == ^nome and not p.vendido,
order_by: p.preco_centavos
)
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp verificacoes do
%{
juncao: [
{"A tabela `product_categories` existe, com `product_id` e `category_id`.",
fn ->
if colunas_de("product_categories") == [] and 20250203192958 in coluna("SELECT version FROM schema_migrations") do
{:erro,
"A migration `CreateProductCategories` está registrada como rodada, mas a tabela não existe: ela rodou vazia. " <>
"Escreva a tabela na célula da migration, avalie-a e depois avalie a célula que desfaz e refaz a migration."}
else
colunas("product_categories", ~w(product_id category_id))
end
end},
{"A tabela não tem a coluna `id`.",
fn ->
if "id" in colunas_de("product_categories"),
do: {:erro, "A tabela tem a coluna `id`. Crie-a com `create table(:product_categories, primary_key: false)`."},
else: :ok
end},
{"`product_id` referencia `products` e `category_id` referencia `categories`, as duas com `ON DELETE CASCADE`.",
fn ->
fks = chaves_estrangeiras("product_categories")
cond do
fks["product_id"] == nil or fks["category_id"] == nil ->
{:erro, "Falta chave estrangeira em #{inspect(Enum.reject(~w(product_id category_id), &fks[&1]))}. Use `references(:products, ...)` e `references(:categories, ...)`."}
fks["product_id"] != {"products", "c"} or fks["category_id"] != {"categories", "c"} ->
{:erro,
"As chaves estão `#{inspect(fks)}` (`\"c\"` é CASCADE, `\"a\"` é o padrão, sem ação). Passe `on_delete: :delete_all` nas duas `references`."}
true ->
:ok
end
end},
{"Há um índice em `product_id`.",
fn ->
if Enum.any?(indices("product_categories"), &String.ends_with?(&1, "(product_id)")),
do: :ok,
else: {:erro, "Crie o índice com `create index(:product_categories, [:product_id])`."}
end},
{"Há um índice único em `(category_id, product_id)`, nessa ordem.",
fn ->
defs = indices("product_categories")
cond do
Enum.any?(defs, &(&1 =~ "UNIQUE" and String.ends_with?(&1, "(category_id, product_id)"))) ->
:ok
Enum.any?(defs, &String.ends_with?(&1, "(category_id, product_id)")) ->
{:erro, "O índice de `(category_id, product_id)` existe, mas não é único. Use `unique_index/2`."}
true ->
{:erro, "Crie `unique_index(:product_categories, [:category_id, :product_id])`. A ordem das colunas importa: `category_id` primeiro."}
end
end}
],
many_to_many: [
{"`Product` tem a associação `categories`, um `many_to_many` com `Category`.",
fn ->
case apply(Hello.Catalog.Product, :__schema__, [:association, :categories]) do
%Ecto.Association.ManyToMany{related: Hello.Catalog.Category} -> :ok
nil -> {:erro, "O schema não tem `categories`. Acrescente o `many_to_many` dentro do bloco `schema` e avalie a célula."}
outra -> {:erro, "`categories` existe, mas é `#{inspect(outra.__struct__)}` com `#{inspect(outra.related)}`."}
end
end},
{"A junção é feita pela tabela `product_categories`.",
fn ->
case apply(Hello.Catalog.Product, :__schema__, [:association, :categories]).join_through do
"product_categories" -> :ok
outra -> {:erro, "`join_through` está `#{inspect(outra)}`. Use `join_through: \"product_categories\"`."}
end
end},
{"A associação usa `on_replace: :delete`.",
fn ->
case apply(Hello.Catalog.Product, :__schema__, [:association, :categories]).on_replace do
:delete -> :ok
outro -> {:erro, "`on_replace` está `#{inspect(outro)}`. Sem `:delete`, trocar as categorias de um produto levanta um erro."}
end
end},
{"Um `%Product{}` novo tem o campo `categories`, ainda não carregado.",
fn ->
case struct(Hello.Catalog.Product) |> Map.get(:categories) do
%Ecto.Association.NotLoaded{} -> :ok
outro -> {:erro, "O campo `categories` veio `#{inspect(outro)}`."}
end
end}
],
change_product: [
{"`Catalog` define `list_categories_by_id/1`.",
fn -> exporta(Hello.Catalog, list_categories_by_id: 1) end},
{"`list_categories_by_id(nil)` devolve `[]`, e uma lista de IDs devolve essas categorias.",
fn ->
livros = id_categoria("Books")
vazia = apply(Hello.Catalog, :list_categories_by_id, [nil])
achadas = apply(Hello.Catalog, :list_categories_by_id, [[to_string(livros)]])
cond do
vazia != [] -> {:erro, "`list_categories_by_id(nil)` devolveu `#{inspect(vazia, limit: 5)}`."}
Enum.map(achadas, & &1.title) != ["Books"] -> {:erro, "Com `[\"#{livros}\"]`, o esperado é a categoria Books; veio `#{inspect(achadas, limit: 5)}`."}
true -> :ok
end
end},
{"`create_product/1` com `\"category_ids\"` grava o produto ligado às categorias.",
fn ->
limpando(fn ->
params = parametros_produto("Conferência Um", ["Books", "Education"])
case apply(Hello.Catalog, :create_product, [params]) do
{:ok, produto} ->
case categorias_no_banco(produto.id) do
["Books", "Education"] -> :ok
[] -> {:erro, "O produto foi gravado sem categorias. `create_product/1` precisa passar por `change_product/2`, que faz o `put_assoc`."}
outras -> {:erro, "O produto ficou com `#{inspect(outras)}`."}
end
{:error, changeset} ->
{:erro, "`create_product/1` devolveu erro: `#{inspect(changeset.errors)}`."}
end
end)
end},
{"`get_product!/1` devolve o produto com as categorias carregadas.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Dois", ["Gardening"])
case apply(Hello.Catalog, :get_product!, [id]).categories do
%Ecto.Association.NotLoaded{} -> {:erro, "As categorias vieram sem carregar. Use `Repo.preload(:categories)` em `get_product!/1`."}
lista -> if Enum.map(lista, & &1.title) == ["Gardening"], do: :ok, else: {:erro, "Vieram `#{inspect(Enum.map(lista, & &1.title))}`."}
end
end)
end},
{"`update_product/2` troca as categorias pelas de `\"category_ids\"`.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Três", ["Books", "Education"])
produto = apply(Hello.Catalog, :get_product!, [id])
case apply(Hello.Catalog, :update_product, [produto, %{"category_ids" => [to_string(id_categoria("Home Improvement"))]}]) do
{:ok, _} ->
case categorias_no_banco(id) do
["Home Improvement"] -> :ok
outras -> {:erro, "Depois da troca, o produto tem `#{inspect(outras)}`. `update_product/2` também precisa passar por `change_product/2`."}
end
{:error, changeset} ->
{:erro, "`update_product/2` devolveu erro: `#{inspect(changeset.errors)}`."}
end
end)
end},
{"`POST /products` com `category_ids` cria o produto com as categorias.",
fn ->
limpando(fn ->
conn = pedir(:post, "/products", %{"product" => parametros_produto("Conferência Quatro", ["Power Tools"])})
cond do
conn.status != 302 -> {:erro, "A resposta veio com status `#{conn.status}`, e não com o redirect 302."}
categorias_por_titulo("Conferência Quatro") != ["Power Tools"] -> {:erro, "O produto foi criado com `#{inspect(categorias_por_titulo("Conferência Quatro"))}`."}
true -> :ok
end
end)
end}
],
formulario: [
{"`ProductHTML` define `category_opts/1`.",
fn -> exporta(HelloWeb.ProductHTML, category_opts: 1) end},
{"`GET /products/new` mostra um `<select multiple>` chamado `product[category_ids][]` com as cinco categorias.",
fn ->
html = pedir(:get, "/products/new") |> corpo()
selects = Regex.scan(~r{<select[^>]*>}, html) |> Enum.map(&hd/1)
cond do
selects == [] ->
{:erro, "A página não tem `<select>`. Acrescente o `<.input field={f[:category_ids]} type=\"select\" multiple ...>` ao `product_form`."}
not Enum.any?(selects, &(&1 =~ ~s(name="product[category_ids][]") and &1 =~ ~r/\smultiple/)) ->
{:erro, "O `<select>` está `#{hd(selects)}`. Falta o atributo `multiple`: sem ele, o nome não ganha `[]` e só uma categoria é enviada."}
Enum.sort(opcoes(html)) != Enum.sort(["Home Improvement", "Power Tools", "Gardening", "Books", "Education"]) ->
{:erro, "As opções são `#{inspect(opcoes(html))}`; o esperado são as cinco categorias dos seeds."}
true ->
:ok
end
end},
{"A página de edição marca as categorias do produto.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Edição", ["Books", "Education"])
case pedir(:get, "/products/#{id}/edit") |> corpo() |> marcadas() |> Enum.sort() do
["Books", "Education"] -> :ok
outras -> {:erro, "As opções marcadas são `#{inspect(outras)}`. Confira o `selected:` em `category_opts/1`."}
end
end)
end},
{"A página do produto lista as categorias.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Página", ["Books", "Education"])
pagina = pedir(:get, "/products/#{id}") |> corpo() |> texto()
cond do
not (pagina =~ "Categories") -> {:erro, "A página não tem o item `Categories`. Acrescente o `<:item title=\"Categories\">` ao `<.list>` de `show`."}
not (pagina =~ "Books" and pagina =~ "Education") -> {:erro, "O item `Categories` existe, mas não lista Books e Education."}
true -> :ok
end
end)
end}
],
formulario_honesto: [
{"Depois de um erro de validação, o formulário marca só as categorias enviadas.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Honesto", ["Books", "Education"])
params = %{"product" => %{"title" => "", "category_ids" => ["", to_string(id_categoria("Home Improvement"))]}}
conn = pedir(:put, "/products/#{id}", params)
case conn |> corpo() |> marcadas() |> Enum.sort() do
["Home Improvement"] -> :ok
[] -> {:erro, "Nenhuma opção marcada. O formulário tem o `<select>` das categorias? (exercício anterior)"}
outras -> {:erro, "Ficaram marcadas `#{inspect(outras)}`. `category_opts/1` deve olhar as categorias que o produto terá: `Ecto.Changeset.get_field(changeset, :categories)`."}
end
end)
end},
{"A página de edição continua marcando as categorias gravadas.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Honesto", ["Books", "Education"])
case pedir(:get, "/products/#{id}/edit") |> corpo() |> marcadas() |> Enum.sort() do
["Books", "Education"] -> :ok
[] -> {:erro, "Nenhuma opção marcada. Se `change_product/2` faz `put_assoc` mesmo sem `\"category_ids\"`, a lista vira `[]` antes de chegar ao formulário."}
outras -> {:erro, "Ficaram marcadas `#{inspect(outras)}`."}
end
end)
end},
{"O formulário tem o campo escondido `product[category_ids][]` com valor vazio.",
fn ->
escondido =
pedir(:get, "/products/new")
|> corpo()
|> then(&Regex.scan(~r{<input[^>]*>}, &1))
|> Enum.map(&hd/1)
|> Enum.any?(&(&1 =~ ~s(type="hidden") and &1 =~ ~s(name="product[category_ids][]") and &1 =~ ~s(value="")))
if escondido,
do: :ok,
else: {:erro, "Acrescente `<input type=\"hidden\" name=\"product[category_ids][]\" value=\"\" />` antes do `<select>`, no `product_form`."}
end},
{"Enviar só o campo escondido (nada marcado) apaga as categorias.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Honesto", ["Books"])
conn = pedir(:put, "/products/#{id}", %{"product" => %{"title" => "Conferência Honesto", "category_ids" => [""]}})
cond do
conn.status != 302 -> {:erro, "A resposta veio com status `#{conn.status}`, e não com o redirect 302."}
categorias_no_banco(id) != [] -> {:erro, "O produto continua com `#{inspect(categorias_no_banco(id))}`."}
true -> :ok
end
end)
end},
{"`update_product/2` sem `\"category_ids\"` mantém as categorias.",
fn ->
limpando(fn ->
id = produto_de_conferencia("Conferência Honesto", ["Books"])
produto = apply(Hello.Catalog, :get_product!, [id])
{:ok, _} = apply(Hello.Catalog, :update_product, [produto, %{"title" => "Conferência Honesto 2"}])
case categorias_no_banco(id) do
["Books"] -> :ok
outras -> {:erro, "O produto ficou com `#{inspect(outras)}`. Só faça o `put_assoc` quando os parâmetros trouxerem `\"category_ids\"`."}
end
end)
end}
],
brecho: [
{"As tabelas `etiquetas` (com `nome`) e `produto_etiquetas` (com `produto_id` e `etiqueta_id`, sem `id`) existem.",
fn ->
with :ok <- colunas("etiquetas", ~w(nome)),
:ok <- colunas("produto_etiquetas", ~w(produto_id etiqueta_id)) do
if "id" in colunas_de("produto_etiquetas"),
do: {:erro, "`produto_etiquetas` tem a coluna `id`. Use `primary_key: false`."},
else: :ok
end
end},
{"O `nome` das etiquetas tem índice único.",
fn ->
if Enum.any?(indices("etiquetas"), &(&1 =~ "UNIQUE" and String.ends_with?(&1, "(nome)"))),
do: :ok,
else: {:erro, "Crie `unique_index(:etiquetas, [:nome])`. É ele que impede duas etiquetas com o mesmo nome."}
end},
{"`Hello.Vitrine.Produto` tem `many_to_many :etiquetas` com `Hello.Vitrine.Etiqueta`.",
fn ->
case apply(Hello.Vitrine.Produto, :__schema__, [:association, :etiquetas]) do
%Ecto.Association.ManyToMany{related: Hello.Vitrine.Etiqueta} -> :ok
nil -> {:erro, "O schema `Hello.Vitrine.Produto` não tem `etiquetas`."}
outra -> {:erro, "`etiquetas` é `#{inspect(outra.__struct__)}` com `#{inspect(outra.related)}`."}
end
end},
{"`Hello.Vitrine` define `etiquetar/2` e `listar_por_etiqueta/1`.",
fn -> exporta(Hello.Vitrine, etiquetar: 2, listar_por_etiqueta: 1) end},
{"`etiquetar/2` cria as etiquetas, sem espaços nas pontas, em minúsculas e sem repetir.",
fn ->
limpando(fn ->
produto = produto_brecho("Conferência Jaqueta", 8000)
case apply(Hello.Vitrine, :etiquetar, [produto, ["Conf-Vintage", " conf-jeans ", "conf-vintage"]]) do
{:ok, _} ->
case etiquetas_no_banco(produto.id) do
["conf-jeans", "conf-vintage"] -> :ok
outras -> {:erro, "O produto ficou com `#{inspect(outras)}`; o esperado é `[\"conf-jeans\", \"conf-vintage\"]`."}
end
outro ->
{:erro, "`etiquetar/2` devolveu `#{inspect(outro, limit: 6)}`; o esperado é `{:ok, produto}`."}
end
end)
end},
{"Etiquetar de novo troca as etiquetas do produto e não duplica as que já existem.",
fn ->
limpando(fn ->
produto = produto_brecho("Conferência Jaqueta", 8000)
{:ok, _} = apply(Hello.Vitrine, :etiquetar, [produto, ["conf-vintage", "conf-jeans"]])
produto = apply(Hello.Vitrine, :buscar_produto!, [produto.id])
case apply(Hello.Vitrine, :etiquetar, [produto, ["conf-vintage", "conf-couro"]]) do
{:ok, _} ->
cond do
etiquetas_no_banco(produto.id) != ["conf-couro", "conf-vintage"] ->
{:erro, "Depois da segunda chamada, o produto tem `#{inspect(etiquetas_no_banco(produto.id))}`."}
coluna("SELECT count(*) FROM etiquetas WHERE nome = 'conf-vintage'") != [1] ->
{:erro, "A tabela `etiquetas` ficou com mais de uma `conf-vintage`."}
true ->
:ok
end
outro ->
{:erro, "A segunda chamada devolveu `#{inspect(outro, limit: 6)}`. Uma etiqueta que já existe não pode virar erro."}
end
end)
end},
{"Oito produtos etiquetados ao mesmo tempo com uma etiqueta nova: todos dão certo, e a etiqueta existe uma vez só.",
fn ->
limpando(fn ->
produtos = for i <- 1..8, do: produto_brecho("Conferência Peça #{i}", 1000 * i)
Logger.configure(level: :info)
resultados =
try do
produtos
|> Task.async_stream(fn p -> sem_excecao(fn -> apply(Hello.Vitrine, :etiquetar, [p, ["conf-nova"]]) end) end, max_concurrency: 8)
|> Enum.map(fn {:ok, r} -> r end)
after
Logger.configure(level: :debug)
end
falhas = Enum.reject(resultados, &match?({:ok, _}, &1))
cond do
falhas != [] ->
{:erro,
"#{length(falhas)} das 8 chamadas falharam, por exemplo: `#{inspect(hd(falhas), limit: 6)}`. Duas chamadas simultâneas tentaram criar a mesma etiqueta; " <>
"crie as etiquetas com `Repo.insert_all(..., on_conflict: :nothing, conflict_target: :nome)` e depois busque-as."}
coluna("SELECT count(*) FROM etiquetas WHERE nome = 'conf-nova'") != [1] ->
{:erro, "A etiqueta `conf-nova` ficou repetida na tabela."}
true ->
:ok
end
end)
end},
{"`listar_por_etiqueta/1` devolve só as peças não vendidas com a etiqueta, da mais barata à mais cara.",
fn ->
limpando(fn ->
for {nome, preco} <- [{"Conferência Cara", 9000}, {"Conferência Vendida", 100}, {"Conferência Barata", 3000}, {"Conferência Outra", 50}] do
produto = produto_brecho(nome, preco)
if nome != "Conferência Outra", do: {:ok, _} = apply(Hello.Vitrine, :etiquetar, [produto, ["conf-lista"]])
if nome == "Conferência Vendida", do: sql("UPDATE produtos SET vendido = true WHERE id = $1", [produto.id])
end
case apply(Hello.Vitrine, :listar_por_etiqueta, ["conf-lista"]) |> Enum.map(& &1.nome) do
["Conferência Barata", "Conferência Cara"] -> :ok
nomes -> {:erro, "Vieram `#{inspect(nomes)}`; o esperado é `[\"Conferência Barata\", \"Conferência Cara\"]`."}
end
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp executar(teste) do
teste.()
rescue
erro -> {:erro, explicar(erro)}
catch
:throw, {:erro, dica} -> {:erro, dica}
end
defp exporta(modulo, funcoes) do
faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)
if faltando == [],
do: :ok,
else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
end
defp colunas(tabela, esperadas) do
existentes = colunas_de(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 colunas_de(tabela) do
coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])
end
defp tabela?(tabela), do: colunas_de(tabela) != []
# %{"coluna" => {"tabela referenciada", ação no DELETE}}; a ação "c" é CASCADE.
defp chaves_estrangeiras(tabela) do
sql(
"""
SELECT a.attname, c.confrelid::regclass::text, c.confdeltype::text
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.conrelid = to_regclass($1) AND c.contype = 'f'
""",
[tabela]
).rows
|> Map.new(fn [coluna, referida, acao] -> {coluna, {referida, acao}} end)
end
defp indices(tabela) do
coluna("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1", [tabela])
end
defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)
defp id_categoria(titulo) do
case coluna("SELECT id FROM categories WHERE title = $1", [titulo]) do
[id] -> id
[] -> throw({:erro, "A categoria `#{titulo}` não existe. Avalie a célula dos seeds (`priv/repo/seeds.exs`)."})
end
end
defp parametros_produto(titulo, categorias) do
%{
"title" => titulo,
"description" => "Produto da conferência",
"price" => "10",
"views" => "0",
"category_ids" => Enum.map(categorias, &to_string(id_categoria(&1)))
}
end
# Cria o produto e as ligações direto no banco, sem depender do código do exercício.
defp produto_de_conferencia(titulo, categorias) do
ids = Enum.map(categorias, &id_categoria/1)
if ids != [] and not tabela?("product_categories") do
throw({:erro, "A tabela `product_categories` não existe. Faça o exercício da tabela de junção e rode o `mix ecto.migrate`."})
end
[id] =
coluna(
"INSERT INTO products (title, description, price, views, inserted_at, updated_at) VALUES ($1, 'Produto da conferência', 10, 0, now(), now()) RETURNING id",
[titulo]
)
for categoria <- ids, do: sql("INSERT INTO product_categories (product_id, category_id) VALUES ($1, $2)", [id, categoria])
id
end
defp categorias_no_banco(id) do
coluna(
"SELECT c.title FROM product_categories pc JOIN categories c ON c.id = pc.category_id WHERE pc.product_id = $1 ORDER BY c.title",
[id]
)
end
defp categorias_por_titulo(titulo) do
case coluna("SELECT id FROM products WHERE title = $1", [titulo]) do
[id] -> categorias_no_banco(id)
[] -> throw({:erro, "O produto `#{titulo}` não foi gravado."})
end
end
defp produto_brecho(nome, preco) do
[id] =
coluna(
"INSERT INTO produtos (nome, preco_centavos, vendido, inserted_at, updated_at) VALUES ($1, $2, false, now(), now()) RETURNING id",
[nome, preco]
)
apply(Hello.Vitrine, :buscar_produto!, [id])
end
defp etiquetas_no_banco(id) do
coluna(
"SELECT e.nome FROM produto_etiquetas pe JOIN etiquetas e ON e.id = pe.etiqueta_id WHERE pe.produto_id = $1 ORDER BY e.nome",
[id]
)
end
defp limpando(teste) do
teste.()
after
if tabela?("product_categories") do
sql("DELETE FROM product_categories WHERE product_id IN (SELECT id FROM products WHERE title LIKE 'Conferência%')")
end
sql("DELETE FROM products WHERE title LIKE 'Conferência%'")
if tabela?("produto_etiquetas") do
sql(
"DELETE FROM produto_etiquetas WHERE produto_id IN (SELECT id FROM produtos WHERE nome LIKE 'Conferência%') " <>
"OR etiqueta_id IN (SELECT id FROM etiquetas WHERE nome LIKE 'conf-%')"
)
end
if tabela?("etiquetas"), do: sql("DELETE FROM etiquetas WHERE nome LIKE 'conf-%'")
if tabela?("produtos"), do: sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
defp sem_excecao(funcao) do
funcao.()
rescue
erro -> {:excecao, erro}
end
# Faz a requisição e devolve o conn; se ela levantar uma exceção, a conferência para com a explicação.
defp pedir(metodo, caminho, parametros \\ nil) do
case requisicao(metodo, caminho, parametros) do
{:ok, conn} -> conn
{:erro, erro} -> throw({:erro, explicar(erro)})
end
end
defp corpo(conn), do: to_string(conn.resp_body)
defp opcoes(html), do: Regex.scan(~r{<option[^>]*>([^<]*)</option>}, html) |> Enum.map(fn [_, titulo] -> String.trim(titulo) end)
@doc "Os títulos das opções marcadas (`selected`) nos `<select>` de uma página."
def marcadas(html) do
Regex.scan(~r{<option([^>]*)>([^<]*)</option>}, html)
|> Enum.filter(fn [_, atributos, _] -> atributos =~ ~r/\bselected\b/ end)
|> Enum.map(fn [_, _, titulo] -> String.trim(titulo) end)
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "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
# Uma struct compilada antes de o schema ganhar um campo.
defp explicar(%MatchError{term: %{__struct__: modulo} = termo}) when is_atom(modulo) do
faltando = if function_exported?(modulo, :__struct__, 0), do: Map.keys(struct(modulo)) -- Map.keys(termo), else: []
if faltando != [] do
curto = modulo |> Module.split() |> List.last()
"Uma struct `%#{curto}{}` velha, sem o campo #{Enum.map_join(faltando, ", ", &"`#{&1}`")}: ela foi compilada antes da mudança no schema. " <>
"Avalie de novo, depois da célula do schema, as células que escrevem `%#{curto}{}` (o contexto e o controller)."
else
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(%Ecto.Query.CastError{value: valor}) do
"A consulta recebeu `#{inspect(valor)}`, que não vira um ID. Se o formulário manda uma string vazia (o campo escondido), tire-a da lista antes da consulta."
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(%Postgrex.Error{postgres: %{code: :foreign_key_violation, message: mensagem}}) do
"O PostgreSQL recusou: `#{mensagem}`. As chaves estrangeiras da tabela de junção precisam de `on_delete: :delete_all`."
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(%RuntimeError{message: mensagem} = erro) do
if mensagem =~ "on_replace" do
"O Ecto recusou trocar a lista da associação: `on_replace` está no padrão, `:raise`. Use `on_replace: :delete` no `many_to_many`."
else
"Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
end
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia In-context Relationships, o terceiro dos guias de modelagem de dados do Phoenix 1.8. O notebook 08 criou o contexto Catalog, com os produtos. Aqui os produtos ganham categorias, e cada produto pode ter várias. O assunto é a relação muitos-para-muitos dentro de um mesmo contexto, em quatro camadas: a tabela de junção no banco, a associação many_to_many no schema, o contexto que traduz os IDs do formulário em categorias e o formulário que mostra tudo isso.
As convenções são as dos notebooks anteriores: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. O log do Ecto aparece na saída das células, com o SQL de cada operação; leia-o.
O guia usa dois comandos de terminal, mix phx.gen.context e mix ecto.gen.migration. O notebook mostra a saída real de cada um no Phoenix 1.8.16 e traz os arquivos gerados como células. A seção "No seu projeto", no fim, volta ao terminal.
Algumas células de demonstração dependem dos exercícios anteriores a elas. Se você pular um exercício, elas falham com um erro (KeyError, relation "product_categories" does not exist) que aponta o que falta: volte, faça o exercício e avalie de novo.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo. Este notebook precisa do PostgreSQL configurado como no notebook 07 (o LEIA-ME.md da trilha tem os comandos).
A aplicação Hello dentro do notebook
A aplicação começa onde o notebook 08 terminou. As primeiras células são as de sempre: a configuração do banco, o Repo, o router, o endpoint, os componentes e os layouts. As da subseção "O catálogo do notebook 08" trazem o contexto Catalog.
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
resources "/products", ProductController
end
end
lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.Static,
at: "/assets",
from: {:phoenix_html, "priv/static"},
only: ~w(phoenix_html.js)
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
O catálogo do notebook 08
O contexto Catalog como o notebook 08 o deixou: o que mix phx.gen.html Catalog Product products title:string description:string price:decimal views:integer gerou, com as mudanças do guia na migration e a função inc_page_views/1. Os comentários @doc gerados ficaram de fora, para encurtar; você os leu no notebook 08.
priv/repo/migrations/20250201185747_create_products.exs
defmodule Hello.Repo.Migrations.CreateProducts do
use Ecto.Migration
def change do
create table(:products) do
add :title, :string
add :description, :string
add :price, :decimal, precision: 15, scale: 6, null: false
add :views, :integer, default: 0, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
lib/hello/catalog/product.ex
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :title, :string
field :description, :string
field :price, :decimal
field :views, :integer
timestamps(type: :utc_datetime)
end
@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [:title, :description, :price, :views])
|> validate_required([:title, :description, :price, :views])
end
end
lib/hello/catalog.ex
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
def list_products do
Repo.all(Product)
end
def get_product!(id), do: Repo.get!(Product, id)
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
def delete_product(%Product{} = product) do
Repo.delete(product)
end
def change_product(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
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
end
lib/hello_web/controllers/product_controller.ex
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
def index(conn, _params) do
products = Catalog.list_products()
render(conn, :index, products: products)
end
def new(conn, _params) do
changeset = Catalog.change_product(%Product{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
case Catalog.create_product(product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
product =
id
|> Catalog.get_product!()
|> Catalog.inc_page_views()
render(conn, :show, product: product)
end
def edit(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
changeset = Catalog.change_product(product)
render(conn, :edit, product: product, changeset: changeset)
end
def update(conn, %{"id" => id, "product" => product_params}) do
product = Catalog.get_product!(id)
case Catalog.update_product(product, product_params) do
{:ok, product} ->
conn
|> put_flash(:info, "Product updated successfully.")
|> redirect(to: ~p"/products/#{product}")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :edit, product: product, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
product = Catalog.get_product!(id)
{:ok, _product} = Catalog.delete_product(product)
conn
|> put_flash(:info, "Product deleted successfully.")
|> redirect(to: ~p"/products")
end
end
lib/hello_web/controllers/product_html.ex e os templates em product_html/. Como no notebook 08, cada arquivo product_html/NOME.html.heex virou uma função NOME(assigns) na própria célula, que é o que embed_templates "product_html/*" faz no projeto.
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
# product_html/index.html.heex
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Products
<:actions>
<.button href={~p"/products/new"}>
<.icon name="hero-plus" /> New Product
</.button>
</:actions>
</.header>
<.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
<:col :let={product} label="Title">{product.title}</:col>
<:col :let={product} label="Description">{product.description}</:col>
<:col :let={product} label="Price">{product.price}</:col>
<:col :let={product} label="Views">{product.views}</:col>
<:action :let={product}>
<div class="sr-only">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</div>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
<:action :let={product}>
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
# product_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Product {@product.id}
<:subtitle>This is a product record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/products"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
</:actions>
</.header>
<.list>
<:item title="Title">{@product.title}</:item>
<:item title="Description">{@product.description}</:item>
<:item title="Price">{@product.price}</:item>
<:item title="Views">{@product.views}</:item>
</.list>
</Layouts.app>
"""
end
# product_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
New Product
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
</Layouts.app>
"""
end
# product_html/edit.html.heex
def edit(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Edit Product {@product.id}
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
</Layouts.app>
"""
end
@doc """
Renders a product form.
The form is defined in the template at
product_html/product_form.html.heex
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :return_to, :string, default: nil
# product_html/product_form.html.heex
def product_form(assigns) do
~H"""
<.form :let={f} for={@changeset} action={@action}>
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:description]} type="text" label="Description" />
<.input field={f[:price]} type="number" label="Price" step="any" />
<.input field={f[:views]} type="number" label="Views" />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
end
end
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
Recrie o estado do banco no fim do notebook 08: a tabela products e um produto, para que as páginas tenham o que mostrar.
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
{:ok, _} =
Hello.Catalog.create_product(%{
title: "Metaprogramming Elixir",
description: "Write Less Code, Get More Done (and Have Fun!)",
price: "15.00",
views: 0
})
Trilha.navegar("ana", :get, "/products")
Trilha.navegar/4 faz a requisição como um navegador faria, em nome de uma visitante (aqui, "ana"), e mostra o texto da página sem as tags. Você pode abrir as mesmas páginas em http://localhost:4010/products.
Categorias: em que contexto?
Na documentação: In-context Relationships, os primeiros parágrafos.
O catálogo funciona, mas os produtos estão soltos. Lojas costumam classificar produtos de vários jeitos ao mesmo tempo: um livro de exercícios pode estar em Books e em Education. O guia começa por uma decisão de projeto: mesmo que, no começo, cada produto tenha uma categoria só, a relação vai ser muitos-para-muitos desde já. Começar com uma categoria por produto e mudar depois obrigaria a mexer no banco, no schema, no contexto e nos formulários.
Por enquanto, uma categoria é só um título. A primeira pergunta é onde ela mora.
Trilha.pergunta(:onde_mora)
O guia escolhe o Catalog, que cuida da exibição dos produtos; classificar faz parte de exibir. E faz uma ressalva que vale para qualquer projeto:
Às vezes é difícil saber se dois recursos pertencem ao mesmo contexto. Nesses casos, prefira contextos separados e reorganize depois, se for preciso. [...] O fato de dois recursos estarem relacionados não quer dizer que pertençam ao mesmo contexto; senão, você acabaria com um contexto enorme, porque a maioria dos recursos de uma aplicação se liga a outros. Resumindo: na dúvida, prefira módulos (contextos) separados.
Gerando as categorias com phx.gen.context
Na documentação: In-context Relationships, a partir de "Run the following command at your project root".
O Phoenix sabe gerar código dentro de um contexto que já existe. O guia usa o phx.gen.context:
$ mix phx.gen.context Catalog Category categories \
title:string:unique --no-scope
Os argumentos são os do phx.gen.html: o contexto (Catalog), o schema (Category), a tabela (categories) e os campos. Há dois detalhes novos:
title:string:uniqueacrescenta um índice único na coluna, além do tipo.--no-scopediz que as categorias não pertencem a um usuário. Em um projeto commix phx.gen.auth, os geradores amarram cada recurso novo ao usuário logado, o escopo (é o assunto do notebook 10). Aqui ainda não há usuários; a opção deixa a intenção explícita: categorias são de todos.
Como o contexto já existe, o gerador pede confirmação. Esta é a saída do Phoenix 1.8.16 neste ponto do projeto (o número da migration vem da data e da hora; aqui ele é o do guia):
You are generating into an existing context.
The Hello.Catalog context currently has 7 functions and 1 file in its directory.
* It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart
* If they are not closely related, another context probably works better
The fact that two entities are related in the database does not mean they belong to the same context.
If you are not sure, prefer creating a new context over adding to the existing one.
Would you like to proceed? [Yn] y
* creating lib/hello/catalog/category.ex
* creating priv/repo/migrations/20250203192325_create_categories.exs
* injecting lib/hello/catalog.ex
* injecting test/hello/catalog_test.exs
* injecting test/support/fixtures/catalog_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrate
As 7 funções são as seis que o phx.gen.html gerou e a inc_page_views/1 do notebook 08. O aviso repete o conselho do guia, agora vindo da ferramenta.
Trilha.pergunta(:gen_context)
Os arquivos marcados com creating são novos. Os marcados com injecting já existiam e ganharam código no fim: o contexto, os testes do contexto e as fixtures, as funções que criam dados para os testes (o gerador acrescenta uma category_fixture/1). As três células abaixo são a migration, o schema e o contexto.
priv/repo/migrations/20250203192325_create_categories.exs
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
Trilha.migracao(20250203192325, Hello.Repo.Migrations.CreateCategories)
O :unique do comando virou create unique_index(:categories, [:title]): o banco recusa duas categorias com o mesmo título.
lib/hello/catalog/category.ex
defmodule Hello.Catalog.Category do
use Ecto.Schema
import Ecto.Changeset
schema "categories" do
field :title, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(category, attrs) do
category
|> cast(attrs, [:title])
|> validate_required([:title])
|> unique_constraint(:title)
end
end
O schema segue o padrão de sempre, com uma linha que o :unique também trouxe: unique_constraint(:title). Quando o banco recusar um título repetido, Repo.insert devolve {:error, changeset} com o erro no campo, em vez de levantar uma exceção. É o par que o notebook 07 montou à mão: o índice no banco e a restrição no changeset.
lib/hello/catalog.ex: o gerador acrescentou ao fim do módulo um alias e seis funções para categorias, com os mesmos nomes das de produtos. Também aqui sem os @doc.
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
def list_products do
Repo.all(Product)
end
def get_product!(id), do: Repo.get!(Product, id)
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
def delete_product(%Product{} = product) do
Repo.delete(product)
end
def change_product(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
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
alias Hello.Catalog.Category
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
O Catalog agora tem duas partes, uma por schema, e continua sendo a porta de entrada: o resto da aplicação chama Catalog.create_category/1, nunca Repo.insert com uma Category.
A tabela de junção
Na documentação: In-context Relationships, a partir de "we need to support a many-to-many relationship".
O schema Category representa uma categoria, mas nada liga categorias a produtos. Em um banco relacional, uma relação muitos-para-muitos precisa de uma terceira tabela, a tabela de junção: cada linha liga um produto a uma categoria. Um produto em Books e em Education tem duas linhas; uma categoria com dez produtos tem dez.
O guia gera uma migration vazia para ela:
$ mix ecto.gen.migration create_product_categories
* creating priv/repo/migrations/20250203192958_create_product_categories.exs
O ecto.gen.migration só cria o arquivo, com o nome do módulo e uma função change/0 vazia. O conteúdo fica por sua conta.
Trilha.pergunta(:chave_primaria)
Sua vez: a tabela de junção
Preencha a migration abaixo com o que o guia descreve:
- a tabela
product_categories, sem chave primária própria (primary_key: false); product_id, uma referência aproducts, ecategory_id, uma referência acategories, as duas comon_delete: :delete_all;- um índice em
product_id; - um índice único em
[:category_id, :product_id], nessa ordem.
A tabela não tem timestamps: o guia não os põe, e uma ligação não precisa deles. A função references/2 cria a coluna com o tipo da chave primária da outra tabela e a chave estrangeira; o nome da coluna é o primeiro argumento de add.
priv/repo/migrations/20250203192958_create_product_categories.exs
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
end
end
Trilha.migracao(20250203192958, Hello.Repo.Migrations.CreateProductCategories)
Trilha.pergunta(:indice_redundante)
Agora o mix ecto.migrate, que roda as duas migrations novas, em ordem de versão:
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
Com a migration preenchida, o log mostra as duas, com a tabela e os índices de cada uma:
[info] == Running 20250203192325 Hello.Repo.Migrations.CreateCategories.change/0 forward
[info] create table categories
[info] create index categories_title_index
[info] == Migrated 20250203192325 in 0.0s
[info] == Running 20250203192958 Hello.Repo.Migrations.CreateProductCategories.change/0 forward
[info] create table product_categories
[info] create index product_categories_product_id_index
[info] create index product_categories_category_id_product_id_index
[info] == Migrated 20250203192958 in 0.0s
Se você avaliou a célula antes de preencher a migration, ela rodou vazia, e a versão 20250203192958 ficou registrada em schema_migrations. Preencher agora e rodar o mix ecto.migrate de novo não adianta: o Ecto vê a versão registrada e não roda nada. No projeto, a saída é mix ecto.rollback seguido de mix ecto.migrate. A célula abaixo faz o mesmo para esta migration: Ecto.Migrator.down/4 a desfaz e o run a aplica de novo. Use-a só neste ponto do notebook: desfazer a migration apaga a tabela e as ligações que houver nela.
Ecto.Migrator.down(Hello.Repo, 20250203192958, Hello.Repo.Migrations.CreateProductCategories)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
\d product_categories mostra o resultado:
Trilha.descrever("product_categories")
As duas colunas são bigint, o tipo do id de products e de categories. Cada references virou uma chave estrangeira (FOREIGN KEY ... REFERENCES ...), e on_delete: :delete_all virou ON DELETE CASCADE. O índice único faz dois trabalhos: impede que o mesmo produto entre duas vezes na mesma categoria e serve às buscas por category_id.
Trilha.conferir(:juncao)
Trilha.solucao(:juncao)
Os primeiros dados: seeds
Na documentação: In-context Relationships, a partir de "we first need real categories to select in our web UI".
Para escolher categorias no formulário, elas precisam existir. O guia as cria no arquivo de seeds, priv/repo/seeds.exs, um script que popula o banco. No projeto, ele roda com mix run priv/repo/seeds.exs; o mix ecto.setup e o mix ecto.reset também o rodam.
priv/repo/seeds.exs
for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
{:ok, _} = Hello.Catalog.create_category(%{title: title})
end
O seed usa a API do contexto, create_category/1, e não o Repo. O log mostra um INSERT por categoria, com os timestamps como ~U[...], porque o schema usa timestamps(type: :utc_datetime); a saída do guia mostra ~N[...], de uma versão em que o padrão era outro tipo.
Avalie a célula uma vez só. Na segunda, o índice único recusa os títulos repetidos, create_category/1 devolve {:error, changeset} e o {:ok, _} = falha com MatchError. Para recomeçar, use Trilha.recomecar() lá em cima e avalie as células de novo, em ordem.
Trilha.consultar("SELECT id, title FROM categories ORDER BY id")
Os id vão de 1 a 5, na ordem da lista: Books é a 4 e Education é a 5.
Trilha.pergunta(:on_delete)
Veja o ON DELETE CASCADE funcionando. A célula cria uma categoria temporária, liga o produto 1 a ela direto no banco (o schema ainda não sabe nada de categorias) e apaga a categoria pelo contexto. Ela depende do exercício da tabela de junção.
{:ok, temporaria} = Hello.Catalog.create_category(%{title: "Temporária"})
Hello.Repo.query!("INSERT INTO product_categories (product_id, category_id) VALUES (1, $1)", [temporaria.id])
antes = Hello.Repo.query!("SELECT product_id, category_id FROM product_categories").rows
{:ok, _} = Hello.Catalog.delete_category(temporaria)
depois = Hello.Repo.query!("SELECT product_id, category_id FROM product_categories").rows
%{antes: antes, depois: depois}
O log mostra um único DELETE FROM "categories". Ninguém mandou apagar a linha de product_categories, e ela sumiu: foi o banco. O guia insiste nesse ponto: com a regra no banco, a integridade não depende de o código lembrar de limpar a junção, e vale também para quem apaga categorias sem passar pelo Catalog.
A associação many_to_many
Na documentação: In-context Relationships, a partir de "we need to let our context know how to associate products and categories".
O banco está pronto; falta o schema saber da ligação. O Ecto declara relações muitos-para-muitos com a macro many_to_many/3:
many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete
:categoriesé o nome do campo na structProduct.Categoryé o schema do outro lado.join_through: "product_categories"é a tabela de junção. Como ela só tem as duas chaves, basta o nome da tabela; não é preciso um schema para ela.on_replace: :deletediz o que fazer quando a lista de categorias de um produto é substituída.
Trilha.pergunta(:on_replace)
Sua vez: categorias no produto
Na célula abaixo, que repete o schema Product, acrescente o alias Hello.Catalog.Category acima do bloco schema e a linha do many_to_many no lugar indicado. Avalie a célula. Ela redefine o módulo; a célula do Product lá em cima continua como estava.
lib/hello/catalog/product.ex
defmodule Hello.Catalog.Product do
use Ecto.Schema
import Ecto.Changeset
schema "products" do
field :title, :string
field :description, :string
field :price, :decimal
field :views, :integer
# Acrescente aqui a associação com as categorias
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
Trilha.conferir(:many_to_many)
Trilha.solucao(:many_to_many)
Uma struct nova agora tem o campo categories, com o valor #Ecto.Association.NotLoaded<association :categories is not loaded>:
%Hello.Catalog.Product{}
O Ecto nunca busca associações por conta própria. Até alguém pedir com Repo.preload/2 (ou com um preload na consulta), o campo guarda esse marcador, e não uma lista vazia: assim não há como confundir "não tem categorias" com "não carreguei as categorias".
O contexto traduz IDs em categorias
Na documentação: In-context Relationships, a partir de "we need to translate the user input of catalog IDs".
O formulário vai mandar as categorias escolhidas como uma lista de IDs em texto, em params["product"]["category_ids"]: algo como ["4", "5"]. Quem decide o que fazer com essa lista é o Catalog. O controller continua chamando create_product/1 e update_product/2, sem saber que categorias existem.
Sua vez: o change_product/2 do guia
Na célula abaixo, que repete o Catalog, faça as mudanças do guia:
- Mova o
alias Hello.Catalog.Categorypara o topo, junto doalias Hello.Catalog.Product. get_product!/1busca o produto e o devolve com as categorias carregadas:Repo.get!(Product, id)seguido deRepo.preload(:categories).create_product/1eupdate_product/2passam a montar o changeset comchange_product/2, em vez deProduct.changeset/2.change_product/2busca as categorias comlist_categories_by_id(attrs["category_ids"]), carrega as categorias atuais do produto (Repo.preload(:categories)), aplicaProduct.changeset/2e põe a lista no changeset comEcto.Changeset.put_assoc(:categories, categories).- Uma função nova,
list_categories_by_id/1: comnil, devolve[]; com uma lista, busca as categorias comRepo.all(from c in Category, where: c.id in ^category_ids).
lib/hello/catalog.ex
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
# Sua vez: faça aqui as mudanças do guia
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
def list_products do
Repo.all(Product)
end
def get_product!(id), do: Repo.get!(Product, id)
def create_product(attrs) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
def delete_product(%Product{} = product) do
Repo.delete(product)
end
def change_product(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
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
alias Hello.Catalog.Category
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
Trilha.conferir(:change_product)
Trilha.solucao(:change_product)
Cada peça tem um motivo:
put_assoc/3recebe a lista inteira de categorias que o produto deve ter, e o Ecto calcula as diferenças com a lista atual: liga as que entraram, desliga as que saíram.- Para calcular essas diferenças, o Ecto precisa da lista atual. Sem o
Repo.preload(:categories)emchange_product/2, oput_assocem um produto vindo do banco levantaattempting to cast or change association categories from Hello.Catalog.Product that was not loaded. Please preload your associations before manipulating them through changesets. - O
preloademget_product!/1serve ao resto da aplicação: o controller e os templates podem usarproduct.categories. c.id in ^category_idsrecebe strings, como"4", e o Ecto as converte para o tipo da colunaidantes de montar o SQL. O formulário não precisa converter nada.
Veja o contexto em ação: o produto 1 entra em Books (a categoria 4). Leia o SQL no log.
produto = Hello.Catalog.get_product!(1)
{:ok, produto} = Hello.Catalog.update_product(produto, %{"category_ids" => ["4"]})
Enum.map(produto.categories, & &1.title)
O log mostra a busca do produto, o preload das categorias atuais, a busca das categorias pelos IDs e, dentro de uma transação (begin ... commit), um INSERT INTO "product_categories". O update não tocou na tabela products: nenhum campo do produto mudou, só a associação.
Trilha.pergunta(:sem_category_ids)
produto = Hello.Catalog.get_product!(1)
{:ok, produto} = Hello.Catalog.update_product(produto, %{"title" => "Metaprogramming Elixir"})
Enum.map(produto.categories, & &1.title)
A lista voltou vazia, e o log mostra o DELETE FROM "product_categories". Guarde este comportamento: ele é útil para o formulário e perigoso para todo o resto. A seção "Para ir além" volta a ele.
Erro como conteúdo: a struct velha
Com o schema e o contexto novos, abra o formulário de produto novo:
Trilha.navegar("ana", :get, "/products/new")
Se você fez os dois exercícios anteriores, a resposta é uma exceção, MatchError, com um mapa que tem __struct__: Hello.Catalog.Product e todos os campos de um produto, menos categories. (Se a página abriu normalmente, volte aos exercícios.)
Trilha.pergunta(:struct_velha)
A ação new/2 do ProductController escreve %Product{}. O compilador expande essa struct literal em um mapa com os campos que Product tinha quando o controller foi compilado, lá no começo do notebook. O Catalog novo recebe esse mapa velho, chama Repo.preload(:categories), e o Ecto não acha o campo onde guardar as categorias.
Em um projeto, isso não acontece: o compilador registra que o controller depende da struct Product em tempo de compilação, e o mix o recompila quando product.ex muda. No notebook, quem recompila é você. A célula abaixo é o mesmo ProductController do começo, sem nenhuma mudança; avaliá-la de novo basta.
lib/hello_web/controllers/product_controller.ex, compilado de novo
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
Trilha.navegar("ana", :get, "/products/new")
A página volta a abrir. A lição vale para todo o resto da trilha: depois de mudar os campos de um schema em um notebook, avalie de novo as células que escrevem a struct dele.
O formulário e a página do produto
Na documentação: In-context Relationships, a partir de "let's expose our new feature to the web".
Falta a parte web: um campo no formulário para escolher categorias e a lista de categorias na página do produto. O guia põe a lógica de montar as opções em uma função da view, category_opts/1, para o template continuar limpo.
Sua vez: escolher categorias no formulário
Na célula abaixo, que repete o ProductHTML:
-
Escreva
category_opts(changeset). Ela pega as categorias do changeset comEcto.Changeset.get_change(changeset, :categories, []), extrai os IDs comEnum.map(& &1.data.id)e devolve, para cada categoria deHello.Catalog.list_categories(), uma lista[key: cat.title, value: cat.id, selected: cat.id in existing_ids]. -
Em
product_form/1, antes do<footer>, acrescente o campo:<.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} /> -
Em
show/1, dentro do<.list>, depois do item Views, acrescente:<:item title="Categories"> <ul> <li :for={cat <- @product.categories}>{cat.title}</li> </ul> </:item>
lib/hello_web/controllers/product_html.ex
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
# Sua vez: category_opts/1, o <select> no formulário e as categorias em show
# product_html/index.html.heex
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Products
<:actions>
<.button href={~p"/products/new"}>
<.icon name="hero-plus" /> New Product
</.button>
</:actions>
</.header>
<.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
<:col :let={product} label="Title">{product.title}</:col>
<:col :let={product} label="Description">{product.description}</:col>
<:col :let={product} label="Price">{product.price}</:col>
<:col :let={product} label="Views">{product.views}</:col>
<:action :let={product}>
<div class="sr-only">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</div>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
<:action :let={product}>
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
# product_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Product {@product.id}
<:subtitle>This is a product record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/products"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
</:actions>
</.header>
<.list>
<:item title="Title">{@product.title}</:item>
<:item title="Description">{@product.description}</:item>
<:item title="Price">{@product.price}</:item>
<:item title="Views">{@product.views}</:item>
</.list>
</Layouts.app>
"""
end
# product_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
New Product
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
</Layouts.app>
"""
end
# product_html/edit.html.heex
def edit(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Edit Product {@product.id}
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
</Layouts.app>
"""
end
@doc """
Renders a product form.
The form is defined in the template at
product_html/product_form.html.heex
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :return_to, :string, default: nil
# product_html/product_form.html.heex
def product_form(assigns) do
~H"""
<.form :let={f} for={@changeset} action={@action}>
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:description]} type="text" label="Description" />
<.input field={f[:price]} type="number" label="Price" step="any" />
<.input field={f[:views]} type="number" label="Views" />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
end
end
Trilha.conferir(:formulario)
Trilha.solucao(:formulario)
O campo f[:category_ids] não existe no schema, e não precisa existir: o formulário só usa o nome para montar o atributo name. Com multiple, o componente input do CoreComponents acrescenta [] ao nome, e o <select> fica name="product[category_ids][]". É o [] que faz o Plug.Parsers juntar as opções marcadas em uma lista. As opções saem de Phoenix.HTML.Form.options_for_select/2, que entende listas com key, value e selected.
O formulário de produto novo, no texto que o navegador mostra ([ ] é uma opção desmarcada, [x] uma marcada):
Trilha.navegar("ana", :get, "/products/new")
Agora o exemplo do guia: "Elixir Flashcards", nas categorias Education e Books. É o que o navegador envia quando você marca as duas opções e clica em Save Product. Faça o mesmo em http://localhost:4010/products/new, se quiser.
Trilha.navegar("ana", :post, "/products", %{
"product" => %{
"title" => "Elixir Flashcards",
"description" => "Flash card set for the Elixir programming language",
"price" => "5",
"views" => "0",
"category_ids" => ["5", "4"]
}
})
Duas diferenças em relação ao texto do guia. O guia mostra Views: 0, mas o show chama inc_page_views/1 desde o notebook 08, e a visita que segue o redirect já conta: aqui aparece 1. E a ordem das categorias pode mudar, porque o preload não ordena nada.
A página de edição marca as categorias do produto:
flashcards = Hello.Repo.get_by!(Hello.Catalog.Product, title: "Elixir Flashcards")
Trilha.navegar("ana", :get, "/products/#{flashcards.id}/edit")
Investigação: o formulário que volta errado
O caminho feliz funciona. Agora um erro comum: Ana abre a edição do Elixir Flashcards, troca as categorias por Home Improvement e, sem querer, apaga o título.
Trilha.pergunta(:formulario_mente)
flashcards = Hello.Repo.get_by!(Hello.Catalog.Product, title: "Elixir Flashcards")
Trilha.navegar("ana", :put, "/products/#{flashcards.id}", %{
"product" => %{"title" => "", "category_ids" => ["1"]}
})
O formulário volta com can't be blank no título e com três categorias marcadas: Home Improvement, que Ana escolheu, e Books e Education, que ela desmarcou. A causa está nas mudanças que category_opts/1 lê. Veja-as com a ação de cada uma:
flashcards = Hello.Catalog.get_product!(flashcards.id)
changeset = Hello.Catalog.change_product(flashcards, %{"title" => "", "category_ids" => ["1"]})
for mudanca <- Ecto.Changeset.get_change(changeset, :categories, []) do
{mudanca.data.title, mudanca.action}
end
Depois do put_assoc, a lista de mudanças tem um changeset para cada categoria que entra e um para cada categoria que sai, com a ação :replace. category_opts/1 usa & &1.data.id de todos e não olha a ação.
E por que a página de edição funciona? Porque o mesmo defeito acerta por acaso:
for mudanca <- Ecto.Changeset.get_change(Hello.Catalog.change_product(flashcards), :categories, []) do
{mudanca.data.title, mudanca.action}
end
Sem "category_ids", change_product/2 põe uma lista vazia: as categorias atuais viram todas :replace, ou seja, "saindo". category_opts/1 as marca justamente porque estão saindo. São dois defeitos que se anulam na página de edição e aparecem juntos depois de um erro de validação. O guia não trata disso.
Para ir além: um formulário que não mente
A correção tem três partes, uma para cada defeito:
change_product/2só faz oput_assocquando os parâmetros trazem"category_ids". Sem a chave, o changeset fica com as categorias que o produto já tem. Isso também resolve a pergunta doupdate_product(product, %{"title" => ...}).category_opts/1marca as categorias que o produto terá, e não as que mudaram. É o queEcto.Changeset.get_field(changeset, :categories)devolve: as categorias atuais quando nada mudou, e a lista nova (sem as que saem) depois de umput_assoc. Os itens da lista são categorias, então o ID é& &1.id.- O campo escondido. Um
<select multiple>sem nada marcado não envia nada, e com a parte 1 o formulário perderia o jeito de tirar todas as categorias. Acrescente, antes do<select>, um<input type="hidden" name="product[category_ids][]" value="" />: ele sempre envia uma string vazia na lista. Emlist_categories_by_id/1, descarte as strings vazias antes da consulta, comEnum.reject(category_ids, &(&1 == "")); sem isso, o Ecto não consegue converter""em um ID e levantaEcto.Query.CastError.
É a mesma técnica que o componente input usa para checkboxes: um campo escondido com false antes do checkbox, para que o valor chegue mesmo desmarcado.
As células abaixo trazem o Catalog e o ProductHTML como o guia os deixou. Corrija-as e avalie as duas.
lib/hello/catalog.ex
defmodule Hello.Catalog do
@moduledoc """
The Catalog context.
"""
# Para ir além: corrija change_product/2 e list_categories_by_id/1
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
alias Hello.Catalog.Category
def list_products do
Repo.all(Product)
end
def get_product!(id) do
Product
|> Repo.get!(id)
|> Repo.preload(:categories)
end
def create_product(attrs) do
%Product{}
|> change_product(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
|> change_product(attrs)
|> Repo.update()
end
def delete_product(%Product{} = product) do
Repo.delete(product)
end
def change_product(%Product{} = product, attrs \\ %{}) do
categories = list_categories_by_id(attrs["category_ids"])
product
|> Repo.preload(:categories)
|> Product.changeset(attrs)
|> Ecto.Changeset.put_assoc(:categories, categories)
end
def list_categories_by_id(nil), do: []
def list_categories_by_id(category_ids) do
Repo.all(from c in Category, where: c.id in ^category_ids)
end
def inc_page_views(%Product{} = product) do
{1, [%Product{views: views}]} =
from(p in Product, where: p.id == ^product.id, select: [:views])
|> Repo.update_all(inc: [views: 1])
put_in(product.views, views)
end
def list_categories do
Repo.all(Category)
end
def get_category!(id), do: Repo.get!(Category, id)
def create_category(attrs) do
%Category{}
|> Category.changeset(attrs)
|> Repo.insert()
end
def update_category(%Category{} = category, attrs) do
category
|> Category.changeset(attrs)
|> Repo.update()
end
def delete_category(%Category{} = category) do
Repo.delete(category)
end
def change_category(%Category{} = category, attrs \\ %{}) do
Category.changeset(category, attrs)
end
end
lib/hello_web/controllers/product_html.ex
defmodule HelloWeb.ProductHTML do
use HelloWeb, :html
# Para ir além: corrija category_opts/1 e acrescente o campo escondido
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_change(:categories, [])
|> Enum.map(& &1.data.id)
for cat <- Hello.Catalog.list_categories() do
[key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
end
# product_html/index.html.heex
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Products
<:actions>
<.button href={~p"/products/new"}>
<.icon name="hero-plus" /> New Product
</.button>
</:actions>
</.header>
<.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
<:col :let={product} label="Title">{product.title}</:col>
<:col :let={product} label="Description">{product.description}</:col>
<:col :let={product} label="Price">{product.price}</:col>
<:col :let={product} label="Views">{product.views}</:col>
<:action :let={product}>
<div class="sr-only">
<.link navigate={~p"/products/#{product}"}>Show</.link>
</div>
<.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
</:action>
<:action :let={product}>
<.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
# product_html/show.html.heex
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Product {@product.id}
<:subtitle>This is a product record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/products"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
</:actions>
</.header>
<.list>
<:item title="Title">{@product.title}</:item>
<:item title="Description">{@product.description}</:item>
<:item title="Price">{@product.price}</:item>
<:item title="Views">{@product.views}</:item>
<:item title="Categories">
<ul>
<li :for={cat <- @product.categories}>{cat.title}</li>
</ul>
</:item>
</.list>
</Layouts.app>
"""
end
# product_html/new.html.heex
def new(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
New Product
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
</Layouts.app>
"""
end
# product_html/edit.html.heex
def edit(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Edit Product {@product.id}
<:subtitle>Use this form to manage product records in your database.</:subtitle>
</.header>
<.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
</Layouts.app>
"""
end
@doc """
Renders a product form.
The form is defined in the template at
product_html/product_form.html.heex
"""
attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true
attr :return_to, :string, default: nil
# product_html/product_form.html.heex
def product_form(assigns) do
~H"""
<.form :let={f} for={@changeset} action={@action}>
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:description]} type="text" label="Description" />
<.input field={f[:price]} type="number" label="Price" step="any" />
<.input field={f[:views]} type="number" label="Views" />
<.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
<footer>
<.button variant="primary">Save Product</.button>
<.button :if={@return_to} href={@return_to}>Cancel</.button>
</footer>
</.form>
"""
end
end
Trilha.conferir(:formulario_honesto)
Trilha.solucao(:formulario_honesto)
Repita a requisição da investigação. Com a correção, só Home Improvement volta marcada. Note os parâmetros: agora o navegador também envia a string vazia do campo escondido.
flashcards = Hello.Repo.get_by!(Hello.Catalog.Product, title: "Elixir Flashcards")
Trilha.navegar("ana", :put, "/products/#{flashcards.id}", %{
"product" => %{"title" => "", "category_ids" => ["", "1"]}
})
Sem roteiro: etiquetas no Brechó
Agora o mesmo assunto sem passo a passo, no Brechó, o mini-marketplace da trilha.
O Brechó até aqui
No notebook 07, o Brechó ganhou a tabela produtos; no notebook 08, o contexto Hello.Vitrine, que lista as peças à venda, cadastra peças e vende cada uma no máximo uma vez. As células abaixo trazem esse estado pronto. Avalie-as em ordem.
lib/hello_web/router.ex, com as rotas do Brechó no scope "/"
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
resources "/products", ProductController
get "/produtos", ProdutoController, :index
post "/produtos", ProdutoController, :create
post "/produtos/:id/vender", ProdutoController, :vender
end
end
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
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
Algumas peças para a 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("ana", :get, "/produtos")
O desafio: etiquetas
Quem procura roupa usada busca por estilo: "vintage", "jeans", "couro". Cada peça pode ter várias etiquetas, e cada etiqueta marca várias peças. Faça:
- Uma migration com a versão
20250321000000que crie a tabelaetiquetas, comnome(texto, obrigatório), timestamps e um índice único emnome, e a tabela de junçãoproduto_etiquetas, semid, comproduto_ideetiqueta_id(as duas comon_delete: :delete_all) e os índices, como na tabelaproduct_categories. Registre-a e rode omix ecto.migrate. - O schema
Hello.Vitrine.Etiqueta, com um changeset que exijanomee declare a restrição única. many_to_many :etiquetasemHello.Vitrine.Produto.Vitrine.etiquetar(produto, nomes): tira os espaços das pontas de cada nome, passa para minúsculas, ignora nomes vazios e repetidos, cria as etiquetas que ainda não existem e deixa a peça com exatamente essas etiquetas (as que ela tinha e não estão na lista saem). Devolve{:ok, produto}. Ela precisa funcionar quando duas chamadas, ao mesmo tempo, criam a mesma etiqueta nova; a conferência testa isso com oito chamadas simultâneas.Vitrine.listar_por_etiqueta(nome): as peças não vendidas com aquela etiqueta, da mais barata para a mais cara.
Duas dicas. A primeira é do erro como conteúdo deste notebook: avalie a célula da Vitrine depois da do Produto, porque cadastrar_produto/1 escreve %Produto{}. A segunda: para criar sem erro uma etiqueta que talvez já exista, procure a opção on_conflict de Repo.insert_all/3.
# priv/repo/migrations/20250321000000_create_etiquetas.exs
# lib/hello/vitrine/etiqueta.ex e lib/hello/vitrine/produto.ex
# lib/hello/vitrine.ex
Para testar à mão (antes de você escrever as funções, a célula falha com UndefinedFunctionError):
jaqueta = Hello.Repo.get_by!(Hello.Vitrine.Produto, nome: "Jaqueta jeans")
Hello.Vitrine.etiquetar(jaqueta, ["Vintage", " jeans "])
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Repita o guia no projeto do notebook 08, o que já tem o Catalog com inc_page_views/1. A correspondência entre o notebook e o projeto:
| Neste notebook | No projeto |
|---|---|
Saída do phx.gen.context mostrada no texto |
mix phx.gen.context Catalog Category categories title:string:unique --no-scope |
| Célula da migration vazia | mix ecto.gen.migration create_product_categories |
Ecto.Migrator.run(..., :up, all: true) |
mix ecto.migrate |
Ecto.Migrator.down/4 seguido de run |
mix ecto.rollback seguido de mix ecto.migrate |
| Célula dos seeds | priv/repo/seeds.exs e mix run priv/repo/seeds.exs |
Trilha.descrever("product_categories") |
\d product_categories no psql -U postgres hello_dev |
Funções index, show, ... no ProductHTML |
Arquivos em lib/hello_web/controllers/product_html/ |
Avaliar de novo a célula do ProductController |
Nada: o mix recompila quem depende da struct |
Trilha.recomecar() |
mix ecto.reset (apaga, cria, migra e roda os seeds) |
Roteiro:
- Rode o
phx.gen.contexte leia o que ele injetou emlib/hello/catalog.ex, emtest/hello/catalog_test.exse emtest/support/fixtures/catalog_fixtures.ex. - Gere a migration da junção, preencha-a, rode
mix ecto.migratee confira a tabela com\d product_categoriesnopsql. - Escreva os seeds e rode
mix run priv/repo/seeds.exs. Rode de novo e leia o erro. - Faça as mudanças no
Product, noCatalog, emproduct_html.ex,product_form.html.heexeshow.html.heex. Note que omix phx.serverrecompila tudo sozinho: oMatchErrorda struct velha não aparece. - Rode
mix teste veja se os testes gerados para produtos e categorias continuam passando. - No navegador, repita a investigação do formulário que volta errado e, se quiser, aplique a correção.
Revisão
Escreva em uma célula de texto, com as suas palavras: como uma relação muitos-para-muitos fica no banco, o que many_to_many, preload e put_assoc fazem, e por que o controller não sabe que categorias existem. Depois compare com os pontos abaixo.
- Dois recursos relacionados não precisam morar no mesmo contexto.
Categoryfoi para oCatalogporque classificar produtos faz parte de exibi-los. Na dúvida, contextos separados. - O
phx.gen.contexté ophx.gen.htmlsem a camada web. Em um contexto que já existe, ele pede confirmação e injeta as funções no fim do módulo. - Uma relação muitos-para-muitos precisa de uma tabela de junção. Ela não precisa de
id(primary_key: false); um índice único no par de chaves impede ligações repetidas e, pelo prefixo à esquerda, serve às buscas pela primeira coluna. on_delete: :delete_allviraON DELETE CASCADE: o banco apaga as ligações junto com o produto ou a categoria, sem código no contexto.many_to_manydeclara a associação no schema. O campo começa comoNotLoadede só vira lista compreload.on_replace: :deletepermite substituir a lista, apagando as ligações que saem.put_assoc/3recebe a lista completa e o Ecto calcula o que ligar e desligar; para isso, a associação precisa estar carregada.- O contexto traduz o que vem do formulário (IDs em texto) em dados do domínio (categorias). O controller continua chamando
create_product/1eupdate_product/2. - Uma struct literal é expandida na compilação. Em um notebook, mudar um schema pede para reavaliar quem escreve a struct; em um projeto, o
mixcuida disso. - O código do guia apaga as categorias quando os parâmetros não trazem
"category_ids"e, depois de um erro de validação, marca as categorias que saíram.get_field/2, oput_assoccondicional e um campo escondido resolvem os dois.
Próximo guia da trilha: Cross-context boundaries, no notebook 10. O carrinho de compras é um contexto novo, separado do catálogo, que depende de produtos e de usuários.