Powered by AppSignal & Oban Pro

Trilha Phoenix · Relacionamentos no contexto

09_relacionamentos_no_contexto.livemd

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(["&amp;", "<", ">", """, "'", "&nbsp;"], fn
      "&amp;" -> "&"
      "<" -> "<"
      ">" -> ">"
      """ -> "\""
      "'" -> "'"
      "&nbsp;" -> " "
    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:unique acrescenta um índice único na coluna, além do tipo.
  • --no-scope diz que as categorias não pertencem a um usuário. Em um projeto com mix 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 a products, e category_id, uma referência a categories, as duas com on_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 struct Product.
  • 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: :delete diz 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:

  1. Mova o alias Hello.Catalog.Category para o topo, junto do alias Hello.Catalog.Product.
  2. get_product!/1 busca o produto e o devolve com as categorias carregadas: Repo.get!(Product, id) seguido de Repo.preload(:categories).
  3. create_product/1 e update_product/2 passam a montar o changeset com change_product/2, em vez de Product.changeset/2.
  4. change_product/2 busca as categorias com list_categories_by_id(attrs["category_ids"]), carrega as categorias atuais do produto (Repo.preload(:categories)), aplica Product.changeset/2 e põe a lista no changeset com Ecto.Changeset.put_assoc(:categories, categories).
  5. Uma função nova, list_categories_by_id/1: com nil, devolve []; com uma lista, busca as categorias com Repo.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/3 recebe 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) em change_product/2, o put_assoc em um produto vindo do banco levanta attempting to cast or change association categories from Hello.Catalog.Product that was not loaded. Please preload your associations before manipulating them through changesets.
  • O preload em get_product!/1 serve ao resto da aplicação: o controller e os templates podem usar product.categories.
  • c.id in ^category_ids recebe strings, como "4", e o Ecto as converte para o tipo da coluna id antes 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:

  1. Escreva category_opts(changeset). Ela pega as categorias do changeset com Ecto.Changeset.get_change(changeset, :categories, []), extrai os IDs com Enum.map(& &1.data.id) e devolve, para cada categoria de Hello.Catalog.list_categories(), uma lista [key: cat.title, value: cat.id, selected: cat.id in existing_ids].

  2. Em product_form/1, antes do <footer>, acrescente o campo:

    <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
    
  3. 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:

  1. change_product/2 só faz o put_assoc quando 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 do update_product(product, %{"title" => ...}).
  2. category_opts/1 marca as categorias que o produto terá, e não as que mudaram. É o que Ecto.Changeset.get_field(changeset, :categories) devolve: as categorias atuais quando nada mudou, e a lista nova (sem as que saem) depois de um put_assoc. Os itens da lista são categorias, então o ID é & &1.id.
  3. 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. Em list_categories_by_id/1, descarte as strings vazias antes da consulta, com Enum.reject(category_ids, &(&1 == "")); sem isso, o Ecto não consegue converter "" em um ID e levanta Ecto.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:

  1. Uma migration com a versão 20250321000000 que crie a tabela etiquetas, com nome (texto, obrigatório), timestamps e um índice único em nome, e a tabela de junção produto_etiquetas, sem id, com produto_id e etiqueta_id (as duas com on_delete: :delete_all) e os índices, como na tabela product_categories. Registre-a e rode o mix ecto.migrate.
  2. O schema Hello.Vitrine.Etiqueta, com um changeset que exija nome e declare a restrição única.
  3. many_to_many :etiquetas em Hello.Vitrine.Produto.
  4. 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.
  5. 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:

  1. Rode o phx.gen.context e leia o que ele injetou em lib/hello/catalog.ex, em test/hello/catalog_test.exs e em test/support/fixtures/catalog_fixtures.ex.
  2. Gere a migration da junção, preencha-a, rode mix ecto.migrate e confira a tabela com \d product_categories no psql.
  3. Escreva os seeds e rode mix run priv/repo/seeds.exs. Rode de novo e leia o erro.
  4. Faça as mudanças no Product, no Catalog, em product_html.ex, product_form.html.heex e show.html.heex. Note que o mix phx.server recompila tudo sozinho: o MatchError da struct velha não aparece.
  5. Rode mix test e veja se os testes gerados para produtos e categorias continuam passando.
  6. 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. Category foi para o Catalog porque classificar produtos faz parte de exibi-los. Na dúvida, contextos separados.
  • O phx.gen.context é o phx.gen.html sem 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_all vira ON DELETE CASCADE: o banco apaga as ligações junto com o produto ou a categoria, sem código no contexto.
  • many_to_many declara a associação no schema. O campo começa como NotLoaded e só vira lista com preload. on_replace: :delete permite substituir a lista, apagando as ligações que saem.
  • put_assoc/3 recebe 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/1 e update_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 mix cuida 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, o put_assoc condicional 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.