Powered by AppSignal & Oban Pro

Trilha Phoenix · Contextos

08_contextos.livemd

Trilha Phoenix · Contextos

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
       ]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: verifica a conexão com o PostgreSQL, mostra tabelas como
  o psql mostraria, registra migrations, faz requisições ao endpoint como um
  navegador faria, confere os exercícios e guarda as perguntas e as soluções.

  Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router
  @repo Hello.Repo

  # --- banco de dados ------------------------------------------------------------

  @doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
  def verificar_postgres(extra \\ []) do
    config = Keyword.merge(apply(@repo, :config, []), extra)
    onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"

    # Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
    resultado =
      try do
        Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
      rescue
        erro -> {:error, erro}
      end

    case resultado do
      {:ok, estado} ->
        Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
        Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")

      {:error, erro} ->
        Kino.Markdown.new(
          "**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
            cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
        )
    end
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
    "**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
      "Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
  end

  defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
    cond do
      mensagem =~ ~r/ident|peer/i ->
        "**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
          "o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."

      mensagem =~ "does not exist" ->
        "**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."

      true ->
        "**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
    end
  end

  defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
    cond do
      mensagem =~ "econnrefused" ->
        "**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."

      mensagem =~ "nxdomain" ->
        "**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."

      true ->
        "**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
    end
  end

  defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."

  @doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations, e esquece os visitantes."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")
    esquecer_visitantes()

    Kino.Markdown.new(
      "O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
    )
  end

  @doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
  def migracao(versao, modulo) do
    :persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
    nome = modulo |> Module.split() |> List.last() |> Macro.underscore()

    Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
  end

  @doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
  def migracoes, do: mapa_migracoes() |> Enum.sort()

  defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})

  @doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
  def descrever(tabela) do
    colunas =
      sql(
        """
        SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
               CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
               coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
        FROM pg_attribute a
        LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
        WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
        ORDER BY a.attnum
        """,
        [tabela]
      )

    if colunas.rows == [] do
      Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
    else
      indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
      lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)

      chaves =
        sql(
          """
          SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
          WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
          """,
          [tabela]
        )

      fks =
        if chaves.rows == [],
          do: "",
          else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)

      Kino.Markdown.new(
        "**`\\d #{tabela}`**\n\n" <>
          tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
      )
    end
  end

  defp sql(consulta, parametros \\ []) do
    apply(@repo, :query!, [consulta, parametros, [log: false]])
  end

  defp tabela_markdown(%{columns: colunas, rows: linhas}) do
    cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
    separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"

    corpo =
      Enum.map(linhas, fn linha ->
        "| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
      end)

    Enum.join([cabecalho, separador | corpo], "\n")
  end

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
  defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"

  # --- requisições -----------------------------------------------------------

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho, parametros \\ nil, conn \\ Phoenix.ConnTest.build_conn()) do
    descartar_respostas_antigas()

    conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, parametros)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  # --- navegador ---------------------------------------------------------------

  @doc """
  Faz uma requisição como um navegador: envia os cookies que o visitante `quem`
  recebeu antes (a sessão, onde ficam a flash e o login) e segue os redirects.
  `quem` é um nome qualquer, como "ana"; cada nome tem os seus próprios cookies.
  Devolve `{:ok, conn}` com a última resposta, ou `{:erro, exceção}`.
  """
  def visitar(quem, metodo, caminho, parametros \\ nil) do
    quem |> seguir(metodo, caminho, parametros, []) |> List.last() |> elem(1)
  end

  @doc "Como `visitar/4`, mas mostra cada passo, a flash e o texto da página final."
  def navegar(quem, metodo \\ :get, caminho, parametros \\ nil) do
    passos = seguir(quem, metodo, caminho, parametros, [])

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{requisicao, resultado}, i} -> linha_passo(i, requisicao, resultado) end)

    cabecalho = [
      "### #{quem}: #{rotulo(metodo, caminho)}",
      "",
      "| # | Requisição | Status | Redireciona para |",
      "| --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> resumo_final(List.last(passos)))
  end

  @doc "Esquece os cookies de todos os visitantes, como se cada um abrisse um navegador novo."
  def esquecer_visitantes do
    for {{__MODULE__, :cookies, _} = chave, _} <- :persistent_term.get(), do: :persistent_term.erase(chave)
    :ok
  end

  defp seguir(quem, metodo, caminho, parametros, feitos) do
    conn = Enum.reduce(cookies(quem), Phoenix.ConnTest.build_conn(), fn {nome, valor}, c -> Plug.Test.put_req_cookie(c, nome, valor) end)
    resultado = requisicao(metodo, caminho, parametros, conn)
    feitos = feitos ++ [{rotulo(metodo, caminho), resultado}]

    with {:ok, resp} <- resultado do
      guardar_cookies(quem, resp)
    end

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399 and length(feitos) < 6,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(quem, :get, destino, nil, feitos)
    else
      _ -> feitos
    end
  end

  defp cookies(quem), do: :persistent_term.get({__MODULE__, :cookies, quem}, %{})

  defp guardar_cookies(quem, resp) do
    novos =
      Enum.reduce(resp.resp_cookies, cookies(quem), fn {nome, opcoes}, acc ->
        if opcoes[:max_age] == 0, do: Map.delete(acc, nome), else: Map.put(acc, nome, opcoes[:value])
      end)

    :persistent_term.put({__MODULE__, :cookies, quem}, novos)
  end

  defp rotulo(metodo, caminho), do: "#{metodo |> to_string() |> String.upcase()} #{caminho}"

  defp linha_passo(i, requisicao, {:ok, conn}) do
    destino = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `#{requisicao}` | `#{conn.status}` | #{if destino == "", do: "", else: "`#{destino}`"} |"
  end

  defp linha_passo(i, requisicao, {:erro, erro}) do
    "| #{i} | `#{requisicao}` | exceção: `#{erro.__struct__ |> inspect()}` | |"
  end

  defp resumo_final({_requisicao, {:ok, conn}}) do
    flash =
      case conn.assigns[:flash] do
        mapa when is_map(mapa) and map_size(mapa) > 0 ->
          "**Flash:** " <> Enum.map_join(mapa, " · ", fn {tipo, msg} -> "#{tipo}: \"#{msg}\"" end) <> "\n\n"

        _ ->
          ""
      end

    flash <> "**Texto da página** (sem as tags):\n\n" <> cerca(texto(to_string(conn.resp_body)), "")
  end

  defp resumo_final({_requisicao, {:erro, erro}}) do
    "**A requisição levantou uma exceção:**\n\n" <> cerca(Exception.format_banner(:error, erro), "")
  end

  @doc "O texto de uma página HTML, sem as tags: o que se lê no navegador."
  def texto(html) do
    conteudo =
      case Regex.run(~r{<main[^>]*>(.*)</main>}s, html) do
        [_, dentro] -> dentro
        nil -> html
      end

    conteudo
    |> String.replace(~r{<(script|style|head)[^>]*>.*?</\1>}s, "")
    |> String.replace(~r{<input[^>]*type="hidden"[^>]*>}, "")
    |> String.replace(~r{<option[^>]*\sselected[^>]*>}, "[x] ")
    |> String.replace(~r{<option[^>]*>}, "[ ] ")
    |> then(&Regex.replace(~r{<input[^>]*\svalue="([^"]*)"[^>]*>}, &1, fn _, valor -> if valor == "", do: " ", else: " [" <> valor <> "] " end))
    |> String.replace(~r{</?(p|div|li|tr|h\d|header|footer|ul|table|thead|tbody|form|label|select|option|section|br)[^>]*>}, "\n")
    |> String.replace(~r{</t[dh]>}, "  ")
    |> String.replace(~r{<[^>]+>}, "")
    |> String.replace(["&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

  # --- rastreio: quem chama quem -------------------------------------------------

  # As funções do Repo que aparecem no rastreio (as internas ficam de fora).
  @api_repo ~w(all all_by one one! get get! get_by get_by! insert insert! update update! delete delete!
               insert_all update_all delete_all preload transaction transact aggregate exists? reload reload!
               insert_or_update stream)a

  @doc """
  Executa `fun` e mostra, em árvore, as actions dos controllers, as funções dos
  módulos `Hello.*` (os contextos) e as chamadas ao `Hello.Repo`: quem chamou quem.
  """
  def rastrear(fun) do
    {_resultado, chamadas} = com_rastreio(fun)

    linhas =
      for {profundidade, modulo, funcao, aridade, _quem} <- chamadas do
        String.duplicate("    ", profundidade) <> "#{inspect(modulo)}.#{funcao}/#{aridade}"
      end

    Kino.Markdown.new("**Quem chamou quem**\n\n" <> cerca(Enum.join(linhas, "\n"), ""))
  end

  # Liga o rastreio de chamadas do próprio processo (as requisições do
  # ConnTest rodam nele), executa `fun` e devolve {resultado, chamadas}.
  # As mensagens de rastreio vão para um processo coletor: um processo não
  # pode ser o próprio rastreador.
  defp com_rastreio(fun) do
    modulos = modulos_rastreados()
    com_retorno = [{:_, [], [{:return_trace}]}]
    coletor = spawn(fn -> coletar_rastros([]) end)

    Enum.each(modulos, &:erlang.trace_pattern({&1, :_, :_}, com_retorno, [:global]))
    :erlang.trace_pattern({@repo, :_, :_}, true, [:global])
    :erlang.trace(self(), true, [:call, {:tracer, coletor}])

    resultado =
      try do
        fun.()
      after
        :erlang.trace(self(), false, [:call])
        Enum.each(modulos, &:erlang.trace_pattern({&1, :_, :_}, false, [:global]))
        :erlang.trace_pattern({@repo, :_, :_}, false, [:global])
      end

    ref = :erlang.trace_delivered(self())

    receive do
      {:trace_delivered, _, ^ref} -> :ok
    end

    send(coletor, {:fim, self()})

    receive do
      {:rastros, ^coletor, mensagens} -> {resultado, montar_chamadas(mensagens)}
    end
  end

  defp modulos_rastreados do
    for {modulo, _} <- :code.all_loaded(),
        nome = Atom.to_string(modulo),
        (String.starts_with?(nome, "Elixir.Hello.") and not String.starts_with?(nome, "Elixir.Hello.Repo")) or
          (String.starts_with?(nome, "Elixir.HelloWeb.") and String.ends_with?(nome, "Controller")),
        not function_exported?(modulo, :__schema__, 1),
        do: modulo
  end

  defp coletar_rastros(acc) do
    receive do
      {:fim, quem} -> send(quem, {:rastros, self(), Enum.reverse(acc)})
      mensagem -> coletar_rastros([mensagem | acc])
    end
  end

  # Cada chamada vira {profundidade, módulo, função, aridade, quem_chamou}.
  defp montar_chamadas(mensagens) do
    {_pilha, saida} =
      Enum.reduce(mensagens, {[], []}, fn
        {:trace, _, :call, {@repo, funcao, args}}, {pilha, saida} ->
          if funcao in @api_repo,
            do: {pilha, [{visiveis(pilha), @repo, funcao, length(args), chamador(pilha)} | saida]},
            else: {pilha, saida}

        {:trace, _, :call, {modulo, funcao, args}}, {pilha, saida} ->
          visivel = funcao not in [:call, :init, :action] and not String.starts_with?(Atom.to_string(funcao), "__")
          saida = if visivel, do: [{visiveis(pilha), modulo, funcao, length(args), chamador(pilha)} | saida], else: saida
          {[{modulo, visivel} | pilha], saida}

        {:trace, _, :return_from, _, _}, {pilha, saida} ->
          {Enum.drop(pilha, 1), saida}
      end)

    Enum.reverse(saida)
  end

  defp visiveis(pilha), do: Enum.count(pilha, &elem(&1, 1))
  defp chamador(pilha), do: Enum.find_value(pilha, fn {modulo, visivel} -> visivel && modulo end)

  # As chamadas ao Repo que `modulo` fez diretamente, sem passar por um contexto.
  defp chamadas_diretas_ao_repo(modulo, fun) do
    {_resultado, chamadas} = com_rastreio(fun)
    for {_, @repo, funcao, aridade, ^modulo} <- chamadas, uniq: true, do: "`Hello.Repo.#{funcao}/#{aridade}`"
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      contexto_plural: %{
        texto:
          "o comando `mix phx.gen.live Post posts title body:text` não diz o nome de nenhum contexto. Onde o gerador coloca o schema e as funções de acesso aos posts?",
        opcoes: [
          a: "Tudo em `MyApp.Post`, que é ao mesmo tempo schema e API.",
          b: "O schema em `MyApp.Posts.Post` e as funções no contexto `MyApp.Posts`, o plural do recurso.",
          c: "O schema em `MyApp.Post` e as funções na LiveView `MyAppWeb.PostLive`.",
          d: "Em lugar nenhum: sem o nome do contexto, o gerador para com um erro."
        ],
        correta: :b,
        explicacao:
          "Quando o nome do contexto fica de fora, o Phoenix usa o plural do recurso. O resultado é o schema `MyApp.Posts.Post`, em `lib/my_app/posts/post.ex`, e o contexto `MyApp.Posts`, que concentra todo o acesso aos dados dos posts. A LiveView chama o contexto; ela não fala com o Repo."
      },
      comments: %{
        texto:
          "em seguida o guia roda `mix phx.gen.live Posts Comment comments post_id:references:posts body:text`. O que o primeiro argumento, `Posts`, decide?",
        opcoes: [
          a: "O nome da tabela dos comentários.",
          b: "Que os comentários ficam no contexto `MyApp.Posts`, ao lado dos posts: o schema vira `MyApp.Posts.Comment` e as funções novas entram em `MyApp.Posts`.",
          c: "Que cada comentário pertence a um post: é ele que cria a chave estrangeira.",
          d: "O nome da LiveView gerada."
        ],
        correta: :b,
        explicacao:
          "O primeiro argumento é o contexto. Como `MyApp.Posts` já existe, o gerador acrescenta funções a ele em vez de criar outro módulo. A chave estrangeira vem de `post_id:references:posts`, e a tabela se chama `comments`, o terceiro argumento. Agrupar recursos relacionados no mesmo contexto deixa visível como as partes do sistema se relacionam."
      },
      carrinho: %{
        texto: "o carrinho de compras guarda produtos do catálogo. Onde o guia decide colocar o carrinho?",
        opcoes: [
          a: "No `Catalog`, porque um carrinho é feito de produtos.",
          b: "Num contexto próprio, `ShoppingCart`: exibir produtos e controlar o que cada pessoa pôs no carrinho são responsabilidades diferentes.",
          c: "No `ProductController`, porque é ele que mostra os produtos.",
          d: "No schema `Product`, num campo `in_cart`."
        ],
        correta: :b,
        explicacao:
          "Os dados se relacionam, mas as regras não. O catálogo cuida da exibição dos produtos; o carrinho, de quem pôs o quê, em que quantidade e a que preço. O guia separa os dois desde o começo, e o contexto `ShoppingCart` aparece no notebook 10."
      },
      rotas_resources: %{
        texto: "o gerador pede uma linha no router: `resources \"/products\", ProductController`. Quantas rotas essa linha cria?",
        opcoes: [
          a: "Uma, para `/products`.",
          b: "Quatro: listar, criar, atualizar e apagar.",
          c: "Sete, uma por action.",
          d: "Oito: as sete actions, com `update` atendendo a `PATCH` e a `PUT`."
        ],
        correta: :d,
        explicacao:
          "`resources` cria as rotas das sete actions REST: `index`, `edit`, `new`, `show`, `create`, `update` e `delete`. A action `update` aparece duas vezes, uma com `PATCH` e outra com `PUT`, por isso são oito linhas. O notebook 03 mostrou essas rotas na seção sobre resources."
      },
      salvar_vazio: %{
        texto: "você envia o formulário de produto com todos os campos em branco. Que resposta o `ProductController.create/2` gerado devolve?",
        opcoes: [
          a: "Um redirect para `/products/new`, com uma flash de erro.",
          b: "Status 200, com o formulário de novo e uma mensagem de erro embaixo de cada campo.",
          c: "Status 422, com os erros em JSON.",
          d: "Status 500, porque o banco recusa a linha."
        ],
        correta: :b,
        explicacao:
          "`Catalog.create_product/1` devolve `{:error, changeset}`, e o controller renderiza o template `new` com esse changeset. O formulário lê os erros do changeset e os mostra ao lado de cada campo. Não há redirect, que perderia o que a pessoa digitou. O banco nem é consultado: com o changeset inválido, o `Repo.insert/1` desiste antes do `INSERT`."
      },
      troca_de_armazenamento: %{
        texto:
          "um dia a lista de produtos passa a vir de um motor de busca de texto, e não mais de uma consulta SQL. Quais módulos precisam mudar?",
        opcoes: [
          a: "O `ProductController` e o `Hello.Catalog`.",
          b: "Só o `Hello.Catalog` (e o que ele usa por dentro): o controller continua chamando `Catalog.list_products/0`.",
          c: "Só os templates.",
          d: "Todos, porque o Repo aparece em todo o código web."
        ],
        correta: :b,
        explicacao:
          "O controller pede a lista de produtos pelo nome da intenção, `list_products/0`, e não sabe de onde ela vem. Trocar a origem dos dados muda o corpo dessa função, e só ele. É o que o guia chama de desacoplar a lógica de negócio e o armazenamento da camada web."
      },
      doc_false: %{
        texto: "`Product.changeset/2` tem `@doc false` logo acima. O que isso quer dizer?",
        opcoes: [
          a: "Que a função é privada: só o próprio schema pode chamá-la.",
          b: "Que a função é pública, mas não faz parte da API do contexto: quem está fora do `Catalog` monta changesets pelo `Catalog`, com `change_product/2`.",
          c: "Que o Ecto não valida os dados que passam por ela.",
          d: "Que a função só existe no ambiente de desenvolvimento."
        ],
        correta: :b,
        explicacao:
          "`@doc false` esconde a função da documentação, mas ela continua pública: o `Catalog` precisa chamá-la, e ele é outro módulo. A convenção diz ao resto do código que aquela função é um detalhe interno. Controllers e templates usam `Catalog.change_product/2`, `create_product/1` e `update_product/2`."
      },
      corrida: %{
        texto:
          "dois visitantes abrem ao mesmo tempo a página de um produto com 13 visualizações. Cada visita lê o produto e grava `Catalog.update_product(product, %{views: product.views + 1})`. Com que valor `views` pode terminar?",
        opcoes: [
          a: "Sempre 15.",
          b: "15 ou 14, conforme a ordem em que as leituras e as gravações acontecem.",
          c: "Sempre 14.",
          d: "Em nenhum: o banco recusa a segunda gravação."
        ],
        correta: :b,
        explicacao:
          "Se as duas visitas leem 13 antes de qualquer uma gravar, as duas gravam 14, e uma visualização se perde. Se uma termina antes de a outra ler, o resultado é 15. O banco não recusa nada: cada `UPDATE` grava um número fixo, calculado com um valor que já pode estar desatualizado."
      },
      update_all: %{
        texto: "na função do guia, `Repo.update_all/2` devolve `{1, [%Product{views: 14}]}`. O que é o `1`?",
        opcoes: [
          a: "O `id` do produto.",
          b: "O número de linhas que o `UPDATE` alterou.",
          c: "O valor que foi somado a `views`.",
          d: "O número de consultas que o Ecto fez."
        ],
        correta: :b,
        explicacao:
          "`update_all/2` devolve o número de linhas alteradas e a lista do que o `select` pediu: aqui, só o campo `views` de cada linha, numa struct `Product` parcial. O padrão `{1, [...]}` também serve de verificação: para um produto que não existe, o resultado seria `{0, []}`, e o casamento de padrões falharia com `MatchError`."
      }
    }
  end

  # --- soluções --------------------------------------------------------------

  @doc "Um botão que revela a solução de um exercício."
  def solucao(id) do
    quadro = Kino.Frame.new()
    botao = Kino.Control.button("Mostrar a solução")

    Kino.listen(botao, fn _evento ->
      Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
    end)

    Kino.Layout.grid([botao, quadro])
  end

  defp solucoes do
    %{
      migration: ~S'''
      defmodule Hello.Repo.Migrations.CreateProducts do
        use Ecto.Migration

        def change do
          create table(:products) do
            add :title, :string
            add :description, :string
            add :price, :decimal, precision: 15, scale: 6, null: false
            add :views, :integer, default: 0, null: false

            timestamps(type: :utc_datetime)
          end
        end
      end

      Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)
      ''',
      importador: ~S'''
      defmodule Hello.Importador do
        @moduledoc "Importa produtos de um texto com uma linha por produto: título;descrição;preço."

        alias Hello.Catalog

        def importar(texto) do
          texto
          |> String.split("\n", trim: true)
          |> Enum.map(fn linha ->
            [title, description, price] = String.split(linha, ";")

            Catalog.create_product(%{
              "title" => title,
              "description" => description,
              "price" => price,
              "views" => "0"
            })
          end)
        end
      end
      ''',
      inc_page_views: ~S'''
      # Dentro de defmodule Hello.Catalog, junto das outras funções:
      def inc_page_views(%Product{} = product) do
        {1, [%Product{views: views}]} =
          from(p in Product, where: p.id == ^product.id, select: [:views])
          |> Repo.update_all(inc: [views: 1])

        put_in(product.views, views)
      end
      ''',
      show: ~S'''
      # Em HelloWeb.ProductController:
      def show(conn, %{"id" => id}) do
        product =
          id
          |> Catalog.get_product!()
          |> Catalog.inc_page_views()

        render(conn, :show, product: product)
      end
      ''',
      brecho: ~S'''
      # lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
      defmodule Hello.Vitrine.Produto do
        use Ecto.Schema
        import Ecto.Changeset

        schema "produtos" do
          field :nome, :string
          field :preco_centavos, :integer
          field :vendido, :boolean, default: false

          timestamps(type: :utc_datetime)
        end

        @doc false
        def changeset(produto, attrs) do
          produto
          |> cast(attrs, [:nome, :preco_centavos])
          |> validate_required([:nome, :preco_centavos])
          |> validate_number(:preco_centavos, greater_than: 0)
        end
      end

      defmodule Hello.Vitrine do
        @moduledoc """
        A vitrine do Brechó: o que está à venda e a venda de cada peça.
        """

        import Ecto.Query, warn: false
        alias Hello.Repo

        alias Hello.Vitrine.Produto

        def listar_disponiveis do
          Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
        end

        def buscar_produto!(id), do: Repo.get!(Produto, id)

        def cadastrar_produto(attrs) do
          %Produto{}
          |> Produto.changeset(attrs)
          |> Repo.insert()
        end

        def change_produto(%Produto{} = produto, attrs \\ %{}) do
          Produto.changeset(produto, attrs)
        end

        @doc """
        Marca a peça como vendida, se ninguém a vendeu antes.

        A verificação e a mudança acontecem num só UPDATE, então duas vendas
        simultâneas da mesma peça nunca dão certo ao mesmo tempo.
        """
        def vender(%Produto{id: id}) do
          consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p

          case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
            {1, [produto]} -> {:ok, produto}
            {0, []} -> {:error, :ja_vendido}
          end
        end
      end

      # No router, no scope "/" com :browser, ao lado das rotas de /produtos:
      post "/produtos/:id/vender", ProdutoController, :vender

      # lib/hello_web/controllers/produto_controller.ex e produto_html.ex
      defmodule HelloWeb.ProdutoController do
        use HelloWeb, :controller

        alias Hello.Vitrine

        def index(conn, _params) do
          render(conn, :index, produtos: Vitrine.listar_disponiveis())
        end

        def create(conn, %{"produto" => attrs}) do
          case Vitrine.cadastrar_produto(attrs) do
            {:ok, produto} ->
              conn |> put_status(201) |> json(%{id: produto.id})

            {:error, changeset} ->
              erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
              conn |> put_status(422) |> json(%{errors: erros})
          end
        end

        def vender(conn, %{"id" => id}) do
          case Vitrine.vender(Vitrine.buscar_produto!(id)) do
            {:ok, produto} ->
              conn |> put_flash(:info, "#{produto.nome} vendido.") |> redirect(to: ~p"/produtos")

            {:error, :ja_vendido} ->
              conn |> put_flash(:error, "Esta peça já foi vendida.") |> redirect(to: ~p"/produtos")
          end
        end
      end

      defmodule HelloWeb.ProdutoHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <.header>Vitrine do Brechó</.header>
            <.table id="produtos" rows={@produtos}>
              <:col :let={produto} label="Peça">{produto.nome}</:col>
              <:col :let={produto} label="Preço">{produto.preco_centavos} centavos</:col>
              <:action :let={produto}>
                <.link href={~p"/produtos/#{produto}/vender"} method="post">Vender</.link>
              </:action>
            </.table>
          </Layouts.app>
          """
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
  def conferir(id) do
    resultado =
      Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
        case executar(teste) do
          :ok -> {:cont, ["- Passou: " <> descricao | feitos]}
          {:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n  " <> dica | feitos]}
        end
      end)

    Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
  end

  defp executar(teste) do
    teste.()
  rescue
    erro -> {:erro, explicar(erro)}
  end

  defp verificacoes do
    %{
      migration: [
        {"A tabela `products` existe, com `title`, `description`, `price`, `views` e os timestamps.",
         fn -> colunas("products", ~w(title description price views inserted_at updated_at)) end},
        {"`price` tem precisão 15, escala 6 e não aceita `NULL`.",
         fn ->
           case coluna_info("price") do
             %{"numeric_precision" => 15, "numeric_scale" => 6, "is_nullable" => "NO"} ->
               :ok

             info ->
               {:erro,
                "A coluna ficou com precisão `#{inspect(info["numeric_precision"])}`, escala `#{inspect(info["numeric_scale"])}` " <>
                  "e `is_nullable` igual a `#{info["is_nullable"]}`. " <> dica_refazer_migration()}
           end
         end},
        {"`views` tem o valor padrão 0 e não aceita `NULL`.",
         fn ->
           case coluna_info("views") do
             %{"column_default" => "0", "is_nullable" => "NO"} ->
               :ok

             info ->
               {:erro,
                "A coluna ficou com padrão `#{inspect(info["column_default"])}` e `is_nullable` igual a `#{info["is_nullable"]}`. " <>
                  dica_refazer_migration()}
           end
         end}
      ],
      importador: [
        {"`Hello.Importador` define `importar/1`.", fn -> exporta(Hello.Importador, importar: 1) end},
        {"Cada linha vira um resultado: `{:ok, produto}` para as válidas e `{:error, changeset}` para a linha sem título.",
         fn ->
           limpando_products(fn ->
             texto =
               "Conferência: Programming Phoenix;Productive |> Reliable |> Fast;42.50\n" <>
                 ";Uma linha sem título;10\n" <>
                 "Conferência: Elixir in Action;Third edition;55"

             case apply(Hello.Importador, :importar, [texto]) do
               [{:ok, %{title: "Conferência: Programming Phoenix"}}, {:error, %Ecto.Changeset{}}, {:ok, %{title: "Conferência: Elixir in Action"}}] ->
                 if coluna("SELECT count(*) FROM products WHERE title LIKE 'Conferência:%' AND views = 0") == [2],
                   do: :ok,
                   else: {:erro, "Os resultados vieram certos, mas a tabela não tem os dois produtos com `views` igual a 0."}

               [{:error, %Ecto.Changeset{errors: erros}} | _] ->
                 if Keyword.has_key?(erros, :views),
                   do: {:erro, "A primeira linha falhou com erro em `views`. O banco tem padrão 0, mas o changeset gerado exige o campo: passe `\"views\" => \"0\"`."},
                   else: {:erro, "A primeira linha, que é válida, falhou com `#{inspect(erros)}`."}

               outro ->
                 {:erro, "Esperada uma lista com um resultado por linha, na ordem das linhas. Veio `#{inspect(outro, limit: 6)}`."}
             end
           end)
         end},
        {"O importador só fala com o `Hello.Catalog`; quem chama o Repo é o contexto.",
         fn ->
           limpando_products(fn ->
             case chamadas_diretas_ao_repo(Hello.Importador, fn -> apply(Hello.Importador, :importar, ["Conferência: Rastreio;Uma linha;1"]) end) do
               [] -> :ok
               diretas -> {:erro, "`Hello.Importador` chamou #{Enum.join(diretas, ", ")} diretamente. Use `Hello.Catalog.create_product/1`."}
             end
           end)
         end}
      ],
      inc_page_views: [
        {"`Hello.Catalog` define `inc_page_views/1`.", fn -> exporta(Hello.Catalog, inc_page_views: 1) end},
        {"A função devolve o produto com `views` um a mais, e o banco guarda o mesmo valor.",
         fn ->
           limpando_products(fn ->
             produto = produto_de_teste(5)
             novo = apply(Hello.Catalog, :inc_page_views, [produto])

             cond do
               not is_struct(novo, Hello.Catalog.Product) ->
                 {:erro, "A função devolveu `#{inspect(novo, limit: 5)}`; o esperado é a struct `%Product{}` com `views` atualizado."}

               novo.views != 6 ->
                 {:erro, "O produto devolvido tem `views` igual a `#{inspect(novo.views)}`; o esperado é 6. Use `put_in(product.views, views)` com o valor que o `select` trouxe."}

               views_no_banco(produto) != 6 ->
                 {:erro, "O banco ficou com `views` igual a `#{views_no_banco(produto)}`; o esperado é 6."}

               true ->
                 :ok
             end
           end)
         end},
        {"100 incrementos simultâneos somam exatamente 100.",
         fn ->
           limpando_products(fn ->
             produto = produto_de_teste(0)

             ao_mesmo_tempo(100, fn -> apply(Hello.Catalog, :inc_page_views, [produto]) end)

             case views_no_banco(produto) do
               100 -> :ok
               n -> {:erro, "Ficou `#{n}`. A função lê o valor e depois grava? O incremento precisa acontecer dentro do `UPDATE`, com `Repo.update_all(consulta, inc: [views: 1])`."}
             end
           end)
         end}
      ],
      show: [
        {"Cada `GET /products/:id` soma uma visualização no banco.",
         fn ->
           limpando_products(fn ->
             produto = produto_de_teste(0)
             requisicao(:get, "/products/#{produto.id}")
             requisicao(:get, "/products/#{produto.id}")

             case views_no_banco(produto) do
               2 -> :ok
               0 -> {:erro, "Duas visitas e `views` continua 0. A action `show` precisa passar o produto por `Catalog.inc_page_views/1`."}
               n -> {:erro, "Depois de duas visitas, `views` ficou `#{n}`; o esperado é 2."}
             end
           end)
         end},
        {"A página mostra o valor já incrementado.",
         fn ->
           limpando_products(fn ->
             produto = produto_de_teste(0)

             case requisicao(:get, "/products/#{produto.id}") do
               {:ok, %{status: 200, resp_body: corpo}} ->
                 if texto(to_string(corpo)) =~ "Views\n1",
                   do: :ok,
                   else: {:erro, "O banco foi atualizado, mas a página não mostra `Views 1`. Renderize o produto que `inc_page_views/1` devolve, não o que veio antes dele."}

               {:ok, conn} ->
                 {:erro, "A página respondeu com status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end}
      ],
      brecho: [
        {"`Hello.Vitrine.Produto` é um schema da tabela `produtos`.",
         fn ->
           cond do
             not Code.ensure_loaded?(Hello.Vitrine.Produto) ->
               {:erro, "Defina o módulo `Hello.Vitrine.Produto`: é o `Hello.Produto` do notebook 07, com o nome novo."}

             not function_exported?(Hello.Vitrine.Produto, :__schema__, 1) ->
               {:erro, "`Hello.Vitrine.Produto` existe, mas não é um schema: falta `use Ecto.Schema` e o bloco `schema \"produtos\"`."}

             apply(Hello.Vitrine.Produto, :__schema__, [:source]) != "produtos" ->
               {:erro, "O schema aponta para a tabela `#{apply(Hello.Vitrine.Produto, :__schema__, [:source])}`; o certo é `produtos`."}

             true ->
               :ok
           end
         end},
        {"`Hello.Vitrine` define `listar_disponiveis/0`, `buscar_produto!/1`, `cadastrar_produto/1` e `vender/1`.",
         fn -> exporta(Hello.Vitrine, listar_disponiveis: 0, buscar_produto!: 1, cadastrar_produto: 1, vender: 1) end},
        {"`cadastrar_produto/1` grava um produto válido e devolve `{:error, changeset}` para preço zero.",
         fn ->
           limpando_produtos(fn ->
             bom = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Camisa", "preco_centavos" => "3000"}])
             ruim = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Grátis", "preco_centavos" => "0"}])

             case {bom, ruim} do
               {{:ok, %{__struct__: Hello.Vitrine.Produto}}, {:error, %Ecto.Changeset{}}} -> :ok
               {{:ok, _}, outro} -> {:erro, "Com preço zero, veio `#{inspect(outro, limit: 5)}`. O changeset precisa de `validate_number/3` com `greater_than: 0`."}
               {outro, _} -> {:erro, "Com dados válidos, veio `#{inspect(outro, limit: 5)}`; o esperado é `{:ok, %Hello.Vitrine.Produto{}}`."}
             end
           end)
         end},
        {"Um formulário não consegue marcar uma peça como vendida: `cadastrar_produto/1` ignora `vendido`.",
         fn ->
           limpando_produtos(fn ->
             case apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => "Conferência Esperta", "preco_centavos" => "100", "vendido" => "true"}]) do
               {:ok, %{vendido: false}} -> :ok
               {:ok, %{vendido: true}} -> {:erro, "A peça foi cadastrada já vendida. Tire `:vendido` da lista do `cast/3` em `Hello.Vitrine.Produto.changeset/2`."}
               outro -> {:erro, "Veio `#{inspect(outro, limit: 5)}`; o esperado é `{:ok, produto}` com `vendido: false`."}
             end
           end)
         end},
        {"`listar_disponiveis/0` traz só os não vendidos, do mais barato ao mais caro.",
         fn ->
           limpando_produtos(fn ->
             for {nome, preco, vendido} <- [{"Conferência Caro", 9000, false}, {"Conferência Vendido", 100, true}, {"Conferência Barato", 1500, false}] do
               sql(
                 "INSERT INTO produtos (nome, preco_centavos, vendido, inserted_at, updated_at) VALUES ($1, $2, $3, now(), now())",
                 [nome, preco, vendido]
               )
             end

             nomes =
               apply(Hello.Vitrine, :listar_disponiveis, [])
               |> Enum.map(& &1.nome)
               |> Enum.filter(&String.starts_with?(&1, "Conferência"))

             case nomes do
               ["Conferência Barato", "Conferência Caro"] -> :ok
               outro -> {:erro, "Entre os produtos de teste, veio `#{inspect(outro)}`; o esperado é `[\"Conferência Barato\", \"Conferência Caro\"]`."}
             end
           end)
         end},
        {"`vender/1` vende uma vez e recusa a segunda venda com `{:error, :ja_vendido}`.",
         fn ->
           limpando_produtos(fn ->
             peca = peca_de_teste("Conferência Bota")

             case {apply(Hello.Vitrine, :vender, [peca]), apply(Hello.Vitrine, :vender, [peca])} do
               {{:ok, %{vendido: true}}, {:error, :ja_vendido}} -> :ok
               {{:ok, %{vendido: false}}, _} -> {:erro, "A primeira venda devolveu o produto com `vendido: false`. Devolva o produto já atualizado."}
               {primeira, segunda} -> {:erro, "As duas vendas devolveram `#{inspect(primeira, limit: 4)}` e `#{inspect(segunda, limit: 4)}`."}
             end
           end)
         end},
        {"20 vendas simultâneas da mesma peça: exatamente uma dá certo.",
         fn ->
           limpando_produtos(fn ->
             peca = peca_de_teste("Conferência Jaqueta")

             resultados = ao_mesmo_tempo(20, fn -> apply(Hello.Vitrine, :vender, [peca]) end)

             case Enum.count(resultados, &match?({:ok, {:ok, _}}, &1)) do
               1 -> :ok
               n -> {:erro, "#{n} das 20 vendas deram certo. A verificação de `vendido` e a mudança precisam estar no mesmo `UPDATE` (um `update_all` com `where: not p.vendido`)."}
             end
           end)
         end},
        {"`POST /produtos/:id/vender` volta para `/produtos` com uma flash de sucesso e, na segunda vez, com uma flash de erro.",
         fn ->
           limpando_produtos(fn ->
             peca = peca_de_teste("Conferência Vestido")
             esquecer_visitantes()

             with {:ok, primeira} <- visitar("conferência", :post, "/produtos/#{peca.id}/vender"),
                  {:ok, segunda} <- visitar("conferência", :post, "/produtos/#{peca.id}/vender") do
               cond do
                 primeira.request_path != "/produtos" or segunda.request_path != "/produtos" ->
                   {:erro, "Depois da venda, a última página foi `#{primeira.request_path}`; o esperado é voltar para `/produtos`."}

                 Phoenix.Flash.get(primeira.assigns.flash, :info) == nil ->
                   {:erro, "A primeira venda não deixou uma flash `:info`."}

                 Phoenix.Flash.get(segunda.assigns.flash, :error) == nil ->
                   {:erro, "A segunda venda da mesma peça não deixou uma flash `:error`."}

                 true ->
                   :ok
               end
             else
               {:erro, erro} -> {:erro, explicar(erro)}
             end
           end)
         end},
        {"A vitrine em `GET /produtos` mostra as peças à venda, cada uma com um link para vender.",
         fn ->
           limpando_produtos(fn ->
             peca = peca_de_teste("Conferência Saia")

             case requisicao(:get, "/produtos") do
               {:ok, %{status: 200, resp_body: corpo}} ->
                 corpo = to_string(corpo)

                 cond do
                   not (corpo =~ "Conferência Saia") -> {:erro, "A página não mostra a peça à venda."}
                   not (corpo =~ ~s(href="/produtos/#{peca.id}/vender")) -> {:erro, "Falta, para cada peça, um `<.link href={~p\"/produtos/\#{produto}/vender\"} method=\"post\">`."}
                   not (corpo =~ ~s(data-method="post")) -> {:erro, "O link de venda precisa de `method=\"post\"`."}
                   true -> :ok
                 end

               {:ok, conn} ->
                 {:erro, "A página respondeu com status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"O `ProdutoController` não chama o Repo: quem fala com o banco é a `Hello.Vitrine`.",
         fn ->
           limpando_produtos(fn ->
             peca = peca_de_teste("Conferência Boné")

             diretas =
               chamadas_diretas_ao_repo(HelloWeb.ProdutoController, fn ->
                 requisicao(:get, "/produtos")
                 requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Cinto", "preco_centavos" => "900"}})
                 requisicao(:post, "/produtos/#{peca.id}/vender")
               end)

             if diretas == [],
               do: :ok,
               else: {:erro, "O controller chamou #{Enum.join(diretas, ", ")} diretamente. Troque cada chamada por uma função da `Hello.Vitrine`."}
           end)
         end}
      ]
    }
  end

  # --- ajudantes deste notebook ----------------------------------------------------

  defp coluna_info(nome) do
    resultado =
      sql(
        """
        SELECT numeric_precision, numeric_scale, is_nullable, column_default
        FROM information_schema.columns
        WHERE table_schema = 'public' AND table_name = 'products' AND column_name = $1
        """,
        [nome]
      )

    case resultado.rows do
      [linha] -> Map.new(Enum.zip(resultado.columns, linha))
      [] -> %{}
    end
  end

  defp dica_refazer_migration do
    "Se a tabela foi criada antes da mudança, desfaça a migration com " <>
      "`Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, step: 1)`, " <>
      "avalie a célula da migration já alterada e rode de novo a célula do `mix ecto.migrate`."
  end

  # Um produto do catálogo criado só para a conferência.
  defp produto_de_teste(views) do
    {:ok, produto} =
      apply(Hello.Catalog, :create_product, [
        %{"title" => "Conferência: contador", "description" => "Produto de teste", "price" => "1", "views" => to_string(views)}
      ])

    produto
  end

  defp views_no_banco(produto) do
    [views] = coluna("SELECT views FROM products WHERE id = $1", [produto.id])
    views
  end

  # Uma peça do Brechó criada só para a conferência.
  defp peca_de_teste(nome) do
    {:ok, peca} = apply(Hello.Vitrine, :cadastrar_produto, [%{"nome" => nome, "preco_centavos" => "2000"}])
    peca
  end

  # Executa a conferência e apaga, no fim, o que ela criou.
  defp limpando_products(teste) do
    teste.()
  after
    if coluna("SELECT to_regclass('public.products')::text") != [nil] do
      sql("DELETE FROM products WHERE title LIKE 'Conferência%'")
    end
  end

  defp limpando_produtos(teste) do
    teste.()
  after
    if coluna("SELECT to_regclass('public.produtos')::text") != [nil] do
      sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
    end
  end

  # Executa `fun` `vezes` vezes ao mesmo tempo, cada uma num processo sem link,
  # e devolve {:ok, resultado} ou {:excecao, erro} de cada uma.
  defp ao_mesmo_tempo(vezes, fun) do
    pai = self()

    silenciando(fn ->
      processos =
        for _ <- 1..vezes do
          spawn_monitor(fn ->
            resultado =
              try do
                {:ok, fun.()}
              rescue
                erro -> {:excecao, erro}
              end

            send(pai, {:resultado, self(), resultado})
          end)
        end

      for {pid, ref} <- processos do
        receive do
          {:resultado, ^pid, resultado} ->
            receive do
              {:DOWN, ^ref, _, _, _} -> resultado
            end

          {:DOWN, ^ref, _, _, motivo} ->
            {:excecao, motivo}
        end
      end
    end)
  end

  # Executa `fun` sem os logs de cada consulta (úteis, mas centenas numa corrida).
  defp silenciando(fun) do
    nivel = Logger.level()
    Logger.configure(level: :info)

    try do
      fun.()
    after
      Logger.configure(level: nivel)
    end
  end

  # --- ajudantes da conferência --------------------------------------------------

  defp exporta(modulo, funcoes) do
    faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)

    if faltando == [],
      do: :ok,
      else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
  end

  defp colunas(tabela, esperadas) do
    existentes = coluna("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [tabela])

    cond do
      existentes == [] -> {:erro, "A tabela `#{tabela}` não existe. Defina a migration, registre-a e rode a célula do `mix ecto.migrate`."}
      (faltando = esperadas -- existentes) != [] -> {:erro, "A tabela `#{tabela}` não tem as colunas #{inspect(faltando)}."}
      true -> :ok
    end
  end

  defp coluna(consulta, parametros \\ []), do: consulta |> sql(parametros) |> Map.fetch!(:rows) |> Enum.map(&hd/1)

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

    cond do
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou."

      true ->
        "`#{nome}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
    end
  end

  defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
    "O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
  end

  defp explicar(%Ecto.ConstraintError{constraint: restricao}) do
    "O banco recusou a operação pela restrição `#{restricao}`, e o changeset não a declara: a violação virou exceção em vez de erro no changeset."
  end

  defp explicar(%Phoenix.ActionClauseError{}) do
    "Etapa: **controller**. Nenhuma cláusula da action casou com os parâmetros recebidos."
  end

  defp explicar(%MatchError{term: termo}) do
    "Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
  end

  defp explicar(erro), do: "Levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook abre o módulo 6 da trilha, sobre contextos. Ele acompanha os dois primeiros capítulos dos guias de modelagem de dados do Phoenix 1.8, Intro to Contexts e Your First Context, na mesma ordem e com o link de cada seção. Os notebooks 09, 10 e 11 seguem com os outros capítulos. O próprio guia avisa que esses capítulos devem ser lidos em ordem: cada um continua a aplicação do anterior.

Até aqui, a trilha tratou das peças isoladas: requisição, router, controller, componentes, Ecto. Os contextos são o lugar onde elas se encontram. É neles que fica o acesso aos dados e as regras do negócio, e é por eles que a parte web conversa com o resto da aplicação.

As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. Há duas ferramentas novas no módulo Trilha:

  • Trilha.navegar(quem, metodo, caminho, parametros) faz uma requisição como um navegador: guarda os cookies de cada visitante (quem é um nome qualquer, como "ana"), segue os redirects e mostra a flash e o texto da página;
  • Trilha.rastrear(fn -> ... end) executa o código e mostra quem chamou quem: a action do controller, a função do contexto e a chamada ao Repo.

O guia usa um gerador, mix phx.gen.html, que um notebook não tem. Aqui, os arquivos que ele cria aparecem como células, com o código que o Phoenix 1.8.16 gerou de verdade. A seção "No seu projeto" volta ao terminal.

Este notebook usa o PostgreSQL configurado no notebook 07. A aplicação fica em http://localhost:4010: além das células, você pode abrir as páginas no navegador e clicar. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

As células desta seção são a aplicação Hello no ponto em que o guia começa: um projeto novo, com Ecto, ainda sem nenhum recurso. São as mesmas peças dos notebooks anteriores, com três novidades que os arquivos gerados pelo Phoenix usam: os CoreComponents completos, os estilos do Tailwind e do daisyUI no root layout e o JavaScript do phoenix_html. O router vem antes dos controllers, por causa do ~p; o aviso HelloWeb.PageController.init/1 is undefined que ele mostra é esperado.

config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.

Application.put_env(:hello, Hello.Repo,
  username: System.get_env("PGUSER", "postgres"),
  password: System.get_env("PGPASSWORD", "postgres"),
  hostname: System.get_env("PGHOST", "localhost"),
  port: String.to_integer(System.get_env("PGPORT", "5432")),
  database: "trilha_phoenix_dev",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10,
  stacktrace: true
)

lib/hello/repo.ex

defmodule Hello.Repo do
  use Ecto.Repo,
    otp_app: :hello,
    adapter: Ecto.Adapters.Postgres
end

lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      import Phoenix.Controller,
        only: [get_csrf_token: 0, view_module: 1, view_template: 1]

      unquote(html_helpers())
    end
  end

  defp html_helpers do
    quote do
      import Phoenix.HTML
      import HelloWeb.CoreComponents

      alias Phoenix.LiveView.JS
      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: HelloWeb.static_paths()
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/router.ex

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
  end
end

lib/hello_web/endpoint.ex: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  @session_options [
    store: :cookie,
    key: "_hello_key",
    signing_salt: "trilha00",
    same_site: "Lax"
  ]

  plug Plug.Static,
    at: "/assets",
    from: {:phoenix_html, "priv/static"},
    only: ~w(phoenix_html.js)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug HelloWeb.Router
end

lib/hello_web/components/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.

defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc "Renders flash notices."
  attr :id, :string, doc: "the optional id of flash container"
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
  attr :title, :string, default: nil
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"

  slot :inner_block, doc: "the optional inner block that renders the flash message"

  def flash(assigns) do
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)

    ~H"""
    <div
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
      id={@id}
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
      role="alert"
      class="toast toast-top toast-end z-50"
      {@rest}
    >
      <div class={[
        "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
        @kind == :info && "alert-info",
        @kind == :error && "alert-error"
      ]}>
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
        <div>
          <p :if={@title} class="font-semibold">{@title}</p>
          <p>{msg}</p>
        </div>
        <div class="flex-1" />
        <button type="button" class="group self-start cursor-pointer" aria-label="close">
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
        </button>
      </div>
    </div>
    """
  end

  @doc "Renders a button with navigation support."
  attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
  attr :class, :any
  attr :variant, :string, values: ~w(primary)
  slot :inner_block, required: true

  def button(%{rest: rest} = assigns) do
    variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}

    assigns =
      assign_new(assigns, :class, fn ->
        ["btn", Map.fetch!(variants, assigns[:variant])]
      end)

    if rest[:href] || rest[:navigate] || rest[:patch] do
      ~H"""
      <.link class={@class} {@rest}>
        {render_slot(@inner_block)}
      </.link>
      """
    else
      ~H"""
      <button class={@class} {@rest}>
        {render_slot(@inner_block)}
      </button>
      """
    end
  end

  @doc "Renders an input with label and error messages."
  attr :id, :any, default: nil
  attr :name, :any
  attr :label, :string, default: nil
  attr :value, :any

  attr :type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file month number password
               search select tel text textarea time url week hidden)

  attr :field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  attr :errors, :list, default: []
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"

  attr :rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "hidden"} = assigns) do
    ~H"""
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <input
          type="hidden"
          name={@name}
          value="false"
          disabled={@rest[:disabled]}
          form={@rest[:form]}
        />
        <span class="label">
          <input
            type="checkbox"
            id={@id}
            name={@name}
            value="true"
            checked={@checked}
            class={@class || "checkbox checkbox-sm"}
            {@rest}
          />{@label}
        </span>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <select
          id={@id}
          name={@name}
          class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
          multiple={@multiple}
          {@rest}
        >
          <option :if={@prompt} value="">{@prompt}</option>
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
        </select>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <textarea
          id={@id}
          name={@name}
          class={[
            @class || "w-full textarea",
            @errors != [] && (@error_class || "textarea-error")
          ]}
          {@rest}
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div class="fieldset mb-2">
      <label for={@id}>
        <span :if={@label} class="label mb-1">{@label}</span>
        <input
          type={@type}
          name={@name}
          id={@id}
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
          class={[
            @class || "w-full input",
            @errors != [] && (@error_class || "input-error")
          ]}
          {@rest}
        />
      </label>
      <.error :for={msg <- @errors}>{msg}</.error>
    </div>
    """
  end

  # Helper used by inputs to generate form errors
  defp error(assigns) do
    ~H"""
    <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
      <.icon name="hero-exclamation-circle" class="size-5" />
      {render_slot(@inner_block)}
    </p>
    """
  end

  @doc "Renders a header with title."
  slot :inner_block, required: true
  slot :subtitle
  slot :actions

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
      <div>
        <h1 class="text-lg font-semibold leading-8">
          {render_slot(@inner_block)}
        </h1>
        <p :if={@subtitle != []} class="text-sm text-base-content/70">
          {render_slot(@subtitle)}
        </p>
      </div>
      <div class="flex-none">{render_slot(@actions)}</div>
    </header>
    """
  end

  @doc "Renders a table with generic styling."
  attr :id, :string, required: true
  attr :rows, :list, required: true
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"

  attr :row_item, :any,
    default: &Function.identity/1,
    doc: "the function for mapping each row before calling the :col and :action slots"

  slot :col, required: true do
    attr :label, :string
  end

  slot :action, doc: "the slot for showing user actions in the last table column"

  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <table class="table table-zebra">
      <thead>
        <tr>
          <th :for={col <- @col}>{col[:label]}</th>
          <th :if={@action != []}>
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
        <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
          <td
            :for={col <- @col}
            phx-click={@row_click && @row_click.(row)}
            class={@row_click && "hover:cursor-pointer"}
          >
            {render_slot(col, @row_item.(row))}
          </td>
          <td :if={@action != []} class="w-0 font-semibold">
            <div class="flex gap-4">
              <%= for action <- @action do %>
                {render_slot(action, @row_item.(row))}
              <% end %>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
    """
  end

  @doc "Renders a data list."
  slot :item, required: true do
    attr :title, :string, required: true
  end

  def list(assigns) do
    ~H"""
    <ul class="list">
      <li :for={item <- @item} class="list-row">
        <div class="list-col-grow">
          <div class="font-bold">{item.title}</div>
          <div>{render_slot(item)}</div>
        </div>
      </li>
    </ul>
    """
  end

  @doc "Renders a [Heroicon](https://heroicons.com)."
  attr :name, :string, required: true
  attr :class, :any, default: "size-4"

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end

  ## JS Commands

  def show(js \\ %JS{}, selector) do
    JS.show(js,
      to: selector,
      time: 300,
      transition:
        {"transition-all ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
    )
  end

  def hide(js \\ %JS{}, selector) do
    JS.hide(js,
      to: selector,
      time: 200,
      transition:
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
    )
  end

  @doc """
  Fills in the error message values. In the generated project this function
  translates the message with Gettext.
  """
  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end

  @doc "Translates the errors for a field from a keyword list of errors."
  def translate_errors(errors, field) when is_list(errors) do
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
  end
end

lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.

defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="csrf-token" content={get_csrf_token()} />
        <title>Hello · Phoenix Framework</title>
        <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
        <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
        </script>
        <script defer src={~p"/assets/phoenix_html.js"}>
        </script>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :current_scope, :map, default: nil, doc: "the current scope"
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header class="navbar px-4 sm:px-6 lg:px-8">
      <div class="flex-1">
        <a href="/" class="flex-1 flex w-fit items-center gap-2">
          <span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
        </a>
      </div>
    </header>

    <main class="px-4 py-20 sm:px-6 lg:px-8">
      <div class="mx-auto max-w-2xl space-y-4">
        {render_slot(@inner_block)}
      </div>
    </main>

    <.flash_group flash={@flash} />
    """
  end

  attr :flash, :map, required: true, doc: "the map of flash messages"
  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"

  def flash_group(assigns) do
    ~H"""
    <div id={@id} aria-live="polite">
      <.flash kind={:info} flash={@flash} />
      <.flash kind={:error} flash={@flash} />
    </div>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro e a página inicial.

defmodule HelloWeb.ErrorHTML do
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

Antes de começar: o PostgreSQL

Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.

Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())

lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.

{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")

Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()

Introdução aos contextos

Na documentação: Intro to Contexts

Os guias do Phoenix se dividem em seções. Na de conceitos centrais, vimos o ciclo de vida da requisição, ligamos actions de controller pelo router e aprendemos como o Ecto valida e grava dados. Agora é hora de juntar tudo: escrever funcionalidades web que conversam com a aplicação Elixir maior.

Um projeto Phoenix é, antes de tudo, uma aplicação Elixir. O papel do Phoenix é dar a essa aplicação uma interface web. A aplicação é feita de módulos e funções, e alguns módulos ganham nomes por causa da responsabilidade que têm: controllers, routers, live views.

A parte mais importante de uma aplicação web, porém, costuma ser o lugar onde ficam o acesso aos dados e a validação deles. O Phoenix chama esses módulos de contextos. Em geral eles falam com um banco de dados, pelo Ecto, ou com APIs externas, por um cliente HTTP como o Req. Dar um nome a esses módulos ajuda a reconhecer o padrão e a conversar sobre ele. No fim das contas, contextos são só módulos, como os controllers e as views.

Quem já usou mix phx.gen.html, mix phx.gen.json ou mix phx.gen.live já usou contextos. O guia dá um exemplo:

$ mix phx.gen.live Post posts title body:text
Trilha.pergunta(:contexto_plural)

O comando cria vários arquivos. Entre eles, o schema MyApp.Posts.Post, em lib/my_app/posts/post.ex, que descreve como o recurso é guardado no banco, e um contexto chamado MyApp.Posts, que concentra todo o acesso aos dados desse schema. O módulo MyApp.Posts reúne tudo o que diz respeito a posts, em vez de espalhar essa lógica por controllers e LiveViews.

Os contextos também servem para agrupar recursos. Para acrescentar comentários aos posts, dá para colocar os schemas lado a lado, já que comentários pertencem a posts:

$ mix phx.gen.live Posts Comment comments post_id:references:posts body:text
Trilha.pergunta(:comments)

O primeiro argumento é o módulo do contexto: ele diz ao Phoenix que os comentários moram junto com os posts. O atributo post_id:references:posts cria uma chave estrangeira para a tabela posts. Conforme a aplicação cresce, os contextos agrupam schemas relacionados, em vez de deixar dezenas de schemas soltos sem nenhuma pista de como se relacionam.

Os contextos também dão nome, de propósito, a partes da aplicação. O mix phx.gen.auth, por exemplo, exige o nome do contexto:

$ mix phx.gen.auth Accounts User users

ou outro nome que você prefira:

$ mix phx.gen.auth Identity Client clients

O contexto gerado, Accounts (ou Identity), concentra tudo sobre gerenciar usuários (ou clientes) e seus tokens. Daria para chamá-lo de Users, mas gestão de contas é uma fronteira bem conhecida na maioria das aplicações, e um nome explícito deixa o propósito claro. E, de novo, são só módulos.

A meta destes capítulos é um sistema de e-commerce: mostrar produtos, deixar cada pessoa pôr produtos no carrinho e fechar pedidos. O guia faz isso projetando e nomeando os contextos com intenção. Na trilha, são quatro notebooks: este, com o catálogo de produtos; o 09, com categorias dentro do catálogo; o 10, com o carrinho, um contexto que depende de outro; e o 11, com os pedidos.

A aplicação de e-commerce

Na documentação: Our ecommerce application

O guia começa uma aplicação do zero, chamada hello, com o Phoenix Express:

$ curl https://new.phoenixframework.org/hello | sh

ou, se o comando não funcionar, com mix phx.new hello, depois de seguir o guia de instalação. Neste notebook, a aplicação já existe: são as células da seção "A aplicação Hello dentro do notebook", lá em cima.

O primeiro contexto

Na documentação: Your First Context

Um e-commerce tem acoplamentos espalhados pelo código, por isso vale pensar bem nos módulos. O primeiro objetivo do guia é uma API de catálogo de produtos: criar, atualizar e apagar os produtos do sistema. Mostrar os produtos vem primeiro; o carrinho vem depois. A ideia é que uma base com fronteiras bem isoladas deixe a aplicação crescer naturalmente.

O Phoenix traz os geradores mix phx.gen.html, mix phx.gen.json, mix phx.gen.live e mix phx.gen.context, que já isolam cada funcionalidade em um contexto. Eles são uma boa forma de começar, e o Phoenix aproveita para empurrar você na direção certa.

Ao rodar um gerador, o nome do contexto é opcional: sem ele, o Phoenix usa o plural do recurso, como você viu com Posts. Isso ajuda quando ainda não está claro como as partes do sistema se relacionam. Um e-commerce, porém, é um domínio bem conhecido, então dá para projetar com intenção. Haverá produtos expostos à venda, com descrição e preço. Além de vender, será preciso ter carrinho, fechamento de pedido e assim por diante.

Trilha.pergunta(:carrinho)

Os produtos comprados se relacionam com o carrinho e com o pedido, mas mostrar um produto e gerenciar a exibição dos produtos é bem diferente de controlar o que alguém pôs no carrinho ou como um pedido é fechado. Um contexto Catalog é o lugar natural para os detalhes dos produtos e para a vitrine.

Começando pelos geradores

Na documentação: Starting with generators

Para começar o catálogo, o guia usa mix phx.gen.html, que cria o contexto com as funções de criar, atualizar e apagar produtos pelo Ecto, e também os arquivos web: controller e templates. O comando, na raiz do projeto:

$ mix phx.gen.html Catalog Product products title:string \
description:string price:decimal views:integer

Os argumentos são o contexto (Catalog), o schema (Product), a tabela (products) e os campos com seus tipos. A saída no Phoenix 1.8.16:

* creating lib/hello_web/controllers/product_controller.ex
* creating lib/hello_web/controllers/product_html/edit.html.heex
* creating lib/hello_web/controllers/product_html/index.html.heex
* creating lib/hello_web/controllers/product_html/new.html.heex
* creating lib/hello_web/controllers/product_html/show.html.heex
* creating lib/hello_web/controllers/product_html/product_form.html.heex
* creating lib/hello_web/controllers/product_html.ex
* creating test/hello_web/controllers/product_controller_test.exs
* creating lib/hello/catalog/product.ex
* creating priv/repo/migrations/20250201185747_create_products.exs
* creating lib/hello/catalog.ex
* injecting lib/hello/catalog.ex
* creating test/hello/catalog_test.exs
* injecting test/hello/catalog_test.exs
* creating test/support/fixtures/catalog_fixtures.ex
* injecting test/support/fixtures/catalog_fixtures.ex

Add the resource to your browser scope in lib/hello_web/router.ex:

    resources "/products", ProductController


Remember to update your repository by running migrations:

    $ mix ecto.migrate

O número no nome da migration é a data e a hora em que o gerador rodou; aqui está o do guia. Repare na divisão entre lib/hello e lib/hello_web. Os arquivos web ficam em lib/hello_web/. Em lib/hello/ ficam o módulo Catalog, a API pública de tudo o que diz respeito ao catálogo, e a struct Catalog.Product, um schema do Ecto que converte e valida os dados de um produto, numa pasta com o nome do contexto.

As linhas injecting mostram que o gerador primeiro cria o arquivo do contexto e depois injeta nele as funções do recurso. É o mesmo mecanismo que, no notebook 09, acrescenta categorias a um contexto que já existe. O gerador também cria testes e funções de apoio para testes (as fixtures); o módulo de testes da trilha volta a eles.

Neste notebook, cada arquivo é uma célula:

Arquivo gerado Célula
priv/repo/migrations/20250201185747_create_products.exs a migration, logo abaixo
lib/hello/catalog/product.ex o schema Hello.Catalog.Product
lib/hello/catalog.ex o contexto Hello.Catalog
lib/hello_web/controllers/product_controller.ex o HelloWeb.ProductController
lib/hello_web/controllers/product_html.ex e product_html/* o HelloWeb.ProductHTML, uma função por template

Antes de rodar o mix ecto.migrate, o guia muda a migration gerada.

priv/repo/migrations/20250201185747_create_products.exs: a migration como o gerador a criou.

defmodule Hello.Repo.Migrations.CreateProducts do
  use Ecto.Migration

  def change do
    create table(:products) do
      add :title, :string
      add :description, :string
      add :price, :decimal
      add :views, :integer

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250201185747, Hello.Repo.Migrations.CreateProducts)

Sua vez: a migration do preço

Faça na célula acima as duas mudanças do guia, antes de rodar o migrate:

-     add :price, :decimal
+     add :price, :decimal, precision: 15, scale: 6, null: false
-     add :views, :integer
+     add :views, :integer, default: 0, null: false

A coluna price ganha precisão 15 e escala 6: até 15 dígitos, 6 deles depois da vírgula, o que guarda valores em dinheiro sem perder precisão nas contas. As duas colunas passam a recusar NULL, e views começa em 0. São regras garantidas pelo banco, valham para quem for que grave na tabela.

Avalie a célula de novo e rode o mix ecto.migrate:

Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

O log tem as mesmas linhas que o guia mostra:

[info] == Running 20250201185747 Hello.Repo.Migrations.CreateProducts.change/0 forward
[info] create table products
[info] == Migrated 20250201185747 in 0.0s
Trilha.descrever("products")

Com as mudanças, price aparece como numeric(15,6) e not null, e views como integer, not null, com padrão 0.

Trilha.conferir(:migration)
Trilha.solucao(:migration)

Se a migration rodou antes da mudança, a tabela já existe com as colunas antigas, e rodar o migrate de novo não muda nada: a versão já está em schema_migrations. A conferência mostra como desfazer e refazer.

Agora os módulos que o gerador criou. A seção "Entendendo o código gerado", mais abaixo, lê cada um com calma; por enquanto, avalie as células na ordem.

lib/hello/catalog/product.ex: o schema.

defmodule Hello.Catalog.Product do
  use Ecto.Schema
  import Ecto.Changeset

  schema "products" do
    field :title, :string
    field :description, :string
    field :price, :decimal
    field :views, :integer

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(product, attrs) do
    product
    |> cast(attrs, [:title, :description, :price, :views])
    |> validate_required([:title, :description, :price, :views])
  end
end

lib/hello/catalog.ex: o contexto, com as seis funções que o gerador escreve para cada recurso.

defmodule Hello.Catalog do
  @moduledoc """
  The Catalog context.
  """

  import Ecto.Query, warn: false
  alias Hello.Repo

  alias Hello.Catalog.Product

  @doc """
  Returns the list of products.

  ## Examples

      iex> list_products()
      [%Product{}, ...]

  """
  def list_products do
    Repo.all(Product)
  end

  @doc """
  Gets a single product.

  Raises `Ecto.NoResultsError` if the Product does not exist.

  ## Examples

      iex> get_product!(123)
      %Product{}

      iex> get_product!(456)
      ** (Ecto.NoResultsError)

  """
  def get_product!(id), do: Repo.get!(Product, id)

  @doc """
  Creates a product.

  ## Examples

      iex> create_product(%{field: value})
      {:ok, %Product{}}

      iex> create_product(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_product(attrs) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a product.

  ## Examples

      iex> update_product(product, %{field: new_value})
      {:ok, %Product{}}

      iex> update_product(product, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_product(%Product{} = product, attrs) do
    product
    |> Product.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a product.

  ## Examples

      iex> delete_product(product)
      {:ok, %Product{}}

      iex> delete_product(product)
      {:error, %Ecto.Changeset{}}

  """
  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking product changes.

  ## Examples

      iex> change_product(product)
      %Ecto.Changeset{data: %Product{}}

  """
  def change_product(%Product{} = product, attrs \\ %{}) do
    Product.changeset(product, attrs)
  end
end

O gerador também pede uma linha no router.

Trilha.pergunta(:rotas_resources)

lib/hello_web/router.ex: o router com a linha que o gerador pediu, no scope "/", que passa pela pipeline :browser. O guia faz essa mudança antes da migration; aqui ela vem agora, antes do controller, porque cada ~p do controller é conferido contra o router quando o módulo compila. No projeto é o mesmo arquivo do começo; aqui, uma célula nova substitui a do topo do notebook.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    # A linha que o gerador pediu:
    resources "/products", ProductController
  end
end

A célula mostra dois avisos esperados: redefining module HelloWeb.Router, porque o módulo já existia, e HelloWeb.ProductController.init/1 is undefined, porque o controller vem logo abaixo. A tabela de rotas, como o mix phx.routes mostraria:

Phoenix.Router.ConsoleFormatter.format(HelloWeb.Router) |> IO.puts()

lib/hello_web/controllers/product_controller.ex: o controller gerado, com as sete actions.

defmodule HelloWeb.ProductController do
  use HelloWeb, :controller

  alias Hello.Catalog
  alias Hello.Catalog.Product

  def index(conn, _params) do
    products = Catalog.list_products()
    render(conn, :index, products: products)
  end

  def new(conn, _params) do
    changeset = Catalog.change_product(%Product{})
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"product" => product_params}) do
    case Catalog.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created successfully.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :new, changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    render(conn, :show, product: product)
  end

  def edit(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    changeset = Catalog.change_product(product)
    render(conn, :edit, product: product, changeset: changeset)
  end

  def update(conn, %{"id" => id, "product" => product_params}) do
    product = Catalog.get_product!(id)

    case Catalog.update_product(product, product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product updated successfully.")
        |> redirect(to: ~p"/products/#{product}")

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, :edit, product: product, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}) do
    product = Catalog.get_product!(id)
    {:ok, _product} = Catalog.delete_product(product)

    conn
    |> put_flash(:info, "Product deleted successfully.")
    |> redirect(to: ~p"/products")
  end
end

lib/hello_web/controllers/product_html.ex e os templates em product_html/. No projeto, embed_templates "product_html/*" transforma cada arquivo product_html/NOME.html.heex em uma função NOME(assigns). Aqui as funções estão escritas na própria célula, uma por arquivo, com o conteúdo gerado. Os componentes <.header>, <.table>, <.list>, <.input> e <.button> vêm dos CoreComponents; <.form> e <.link>, do Phoenix.Component.

defmodule HelloWeb.ProductHTML do
  use HelloWeb, :html

  # product_html/index.html.heex
  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Listing Products
        <:actions>
          <.button href={~p"/products/new"}>
            <.icon name="hero-plus" /> New Product
          </.button>
        </:actions>
      </.header>

      <.table id="products" rows={@products} row_click={&JS.navigate(~p"/products/#{&1}")}>
        <:col :let={product} label="Title">{product.title}</:col>
        <:col :let={product} label="Description">{product.description}</:col>
        <:col :let={product} label="Price">{product.price}</:col>
        <:col :let={product} label="Views">{product.views}</:col>
        <:action :let={product}>
          <div class="sr-only">
            <.link navigate={~p"/products/#{product}"}>Show</.link>
          </div>
          <.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
        </:action>
        <:action :let={product}>
          <.link href={~p"/products/#{product}"} method="delete" data-confirm="Are you sure?">
            Delete
          </.link>
        </:action>
      </.table>
    </Layouts.app>
    """
  end

  # product_html/show.html.heex
  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Product {@product.id}
        <:subtitle>This is a product record from your database.</:subtitle>
        <:actions>
          <.button navigate={~p"/products"}>
            <.icon name="hero-arrow-left" />
          </.button>
          <.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
            <.icon name="hero-pencil-square" /> Edit product
          </.button>
        </:actions>
      </.header>

      <.list>
        <:item title="Title">{@product.title}</:item>
        <:item title="Description">{@product.description}</:item>
        <:item title="Price">{@product.price}</:item>
        <:item title="Views">{@product.views}</:item>
      </.list>
    </Layouts.app>
    """
  end

  # product_html/new.html.heex
  def new(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        New Product
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  # product_html/edit.html.heex
  def edit(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <.header>
        Edit Product {@product.id}
        <:subtitle>Use this form to manage product records in your database.</:subtitle>
      </.header>

      <.product_form changeset={@changeset} action={~p"/products/#{@product}"} return_to={~p"/products"} />
    </Layouts.app>
    """
  end

  @doc """
  Renders a product form.

  The form is defined in the template at
  product_html/product_form.html.heex
  """
  attr :changeset, Ecto.Changeset, required: true
  attr :action, :string, required: true
  attr :return_to, :string, default: nil

  # product_html/product_form.html.heex
  def product_form(assigns) do
    ~H"""
    <.form :let={f} for={@changeset} action={@action}>
      <.input field={f[:title]} type="text" label="Title" />
      <.input field={f[:description]} type="text" label="Description" />
      <.input field={f[:price]} type="number" label="Price" step="any" />
      <.input field={f[:views]} type="number" label="Views" />
      <footer>
        <.button variant="primary">Save Product</.button>
        <.button :if={@return_to} href={@return_to}>Cancel</.button>
      </footer>
    </.form>
    """
  end
end

new e edit usam o mesmo formulário, product_form, um componente de função com três atributos. O que muda entre os dois é o action do formulário: POST /products para criar, /products/ID para atualizar. Nesse segundo caso, o <.form> percebe que o changeset é de um produto que já existe e acrescenta um campo escondido _method com o valor put.

Usando a aplicação

O guia sobe o servidor com mix phx.server e visita http://localhost:4000/products. Aqui o servidor já está no ar, em http://localhost:4010/products: abra no navegador, se quiser, e repita cada passo clicando. As células abaixo fazem o mesmo caminho com Trilha.navegar/4.

A lista, ainda vazia:

Trilha.navegar("ana", :get, "/products")

O link "New Product" leva ao formulário:

Trilha.navegar("ana", :get, "/products/new")

O guia clica em "Save Product" sem preencher nada.

Trilha.pergunta(:salvar_vazio)

O navegador manda os quatro campos, mesmo vazios. A célula faz o mesmo:

Trilha.navegar("ana", :post, "/products", %{
  "product" => %{"title" => "", "description" => "", "price" => "", "views" => ""}
})

Status 200 e o formulário de novo, com can't be blank embaixo de cada campo. O gerador incluiu todos os campos do schema no formulário e no validate_required, e as validações já valem.

Um detalhe do Phoenix 1.8: o formulário só mostra o erro de um campo que veio nos parâmetros. Se a requisição traz só o título, só o título ganha mensagem, mesmo que o changeset tenha erros nos quatro campos. É o que o <.input> decide com Phoenix.Component.used_input?/1: não acusar campos que a pessoa nem tocou.

Trilha.navegar("ana", :post, "/products", %{"product" => %{"title" => ""}})

Agora com dados de verdade, os do guia:

Trilha.navegar("ana", :post, "/products", %{
  "product" => %{
    "title" => "Metaprogramming Elixir",
    "description" => "Write Less Code, Get More Done (and Have Fun!)",
    "price" => "15",
    "views" => "0"
  }
})

Um POST com status 302 e um GET /products/1, que mostra a flash Product created successfully. e o produto: Price aparece como 15.000000, porque a coluna guarda 6 casas decimais (sem a mudança na migration, apareceria 15), e Views é 0. O id é 1 porque o banco foi recriado no começo do notebook; se você avaliou alguma célula de criação mais de uma vez, ajuste os números nas células seguintes.

O link de voltar leva à lista, que agora tem o produto:

Trilha.navegar("ana", :get, "/products")

Editar usa o mesmo formulário, preenchido:

Trilha.navegar("ana", :get, "/products/1/edit")
Trilha.navegar("ana", :put, "/products/1", %{"product" => %{"price" => "12.5"}})

Para apagar, o link "Delete" da lista tem method="delete". Um link comum só faz GET; quem transforma o clique num envio é o JavaScript do phoenix_html, servido pelo endpoint deste notebook. Ele monta um formulário com POST e um campo _method igual a delete, e o Plug.MethodOverride do endpoint troca o método antes de a requisição chegar ao router. A célula cria um segundo produto e o apaga do mesmo jeito:

{:ok, outro} =
  Hello.Catalog.create_product(%{
    title: "Programming Phoenix",
    description: "Productive |> Reliable |> Fast",
    price: 42,
    views: 0
  })

Trilha.navegar("ana", :post, "/products/#{outro.id}", %{"_method" => "delete"})

Agora que você viu a aplicação funcionando, é hora de olhar o código gerado.

Dar nome é difícil. Quando o domínio do negócio não é tão conhecido quanto um e-commerce, pode ser difícil traçar as fronteiras e dar nome aos contextos. Por isso os geradores aceitam ficar sem o nome do contexto: o código acima funcionaria igual com o contexto padrão, Products, e ainda deixaria descobrir aos poucos outros recursos que pertencem a ele, como categorias ou galerias de imagens. O guia também desaconselha nomes espertos: escolha um nome claro para todos que trabalham (ou vão trabalhar) no projeto. Quando as partes do sistema ficarem claras, renomeie ou mova os recursos. Em Elixir, mover um módulo é trocar o nome dele e de quem o chama (e, por consistência, o nome do arquivo).

Entendendo o código gerado

Na documentação: Grokking generated code

Aquele mix phx.gen.html fez bastante coisa: criar, atualizar e apagar produtos já funciona. Está longe de uma aplicação completa, mas os geradores são, antes de tudo, ferramentas de aprendizado e um ponto de partida. Eles não resolvem tudo, mas mostram como o Phoenix organiza as coisas e empurram você para o jeito certo de projetar a aplicação.

Comece pelo caminho de uma requisição. Trilha.rastrear/1 mostra quem chamou quem enquanto a lista de produtos é montada:

Trilha.rastrear(fn -> Trilha.visitar("ana", :get, "/products") end)
Trilha.pergunta(:troca_de_armazenamento)

O controller não chama o Repo. A action index pede a lista com Catalog.list_products/0, e só o Catalog fala com o banco. Volte à célula do ProductController: as actions chamam Catalog.list_products/0, Catalog.create_product/1, Catalog.get_product!/1. O controller ainda não sabe como os produtos são buscados ou gravados, e esse é justamente o ponto. Ele é a interface web da aplicação: pede que algo seja feito e decide o que responder. Se um dia os produtos vierem de um motor de busca de texto em vez de uma consulta SQL, o controller não muda. E o mesmo código do contexto pode ser usado por qualquer outra interface: um channel, uma tarefa do Mix, um processo que importa um CSV.

Na action create, quando o produto é criado, o controller usa Phoenix.Controller.put_flash/3 para guardar a mensagem de sucesso e redireciona para a página do produto. Quando Catalog.create_product/1 falha, ele renderiza o template new de novo, com o changeset, de onde o formulário tira as mensagens de erro. O rastreio da criação mostra as duas requisições, a do POST e a do redirect:

Trilha.rastrear(fn ->
  Trilha.visitar("ana", :post, "/products", %{
    "product" => %{"title" => "Elixir in Action", "description" => "Third edition", "price" => "55", "views" => "0"}
  })
end)

Agora o contexto. Na célula do Hello.Catalog, list_products/0 é só Repo.all(Product), a consulta que o notebook 07 mostrou. O nome da função diz a intenção do código, listar produtos; o detalhe de que a lista vem do PostgreSQL pelo Repo fica escondido de quem chama. É um tema que os geradores repetem: pensar onde estão as diferentes responsabilidades e esconder cada uma atrás de módulos e funções com nomes claros.

Este módulo é a API pública de tudo o que diz respeito ao catálogo. Além dos detalhes dos produtos, ele pode cuidar, por exemplo, da classificação em categorias e de variantes, como tamanhos e acabamentos.

create_product/1 tem mais documentação do que código. Por baixo, de novo, o Repo, e antes dele, o Product.changeset/2. Um contexto é um módulo como outro qualquer, e dá para usá-lo direto, como no IEx:

Hello.Catalog.list_products() |> Enum.map(& &1.title)
Hello.Catalog.create_product(%{title: "Sem preço"})

O resultado é {:error, changeset}, com description, price e views em branco. O changeset vem de Product.changeset/2, no schema.

Trilha.pergunta(:doc_false)

O schema é o que o notebook 07 gerou com mix phx.gen.schema, com uma diferença: o @doc false acima de changeset/2. A função é pública, porque o Catalog precisa chamá-la, mas não faz parte da API do contexto. Quem monta changesets fora do contexto o faz pelo contexto: Catalog.create_product/1 chama Product.changeset/2 com os dados que vieram da web, e o controller nunca chama Product.changeset/2 direto. Toda interação com os changesets de produto passa pelo Catalog.

Sua vez: outra porta para o mesmo contexto

O guia diz que o código do contexto serve a qualquer interface, como um processo que importa dados de um CSV. Escreva essa interface. Na célula abaixo, defina o módulo Hello.Importador com uma função importar/1, que recebe um texto com um produto por linha, os campos separados por ;:

Programming Phoenix;Productive |> Reliable |> Fast;42.50
;Uma linha sem título;10

Para cada linha, importar/1 cria o produto e devolve o resultado de Catalog.create_product/1: uma lista com um {:ok, product} ou {:error, changeset} por linha, na ordem das linhas. Os produtos importados começam com views igual a 0. O importador não deve chamar o Repo: quem grava é o contexto.

# lib/hello/importador.ex
Trilha.conferir(:importador)
Trilha.solucao(:importador)

Um detalhe que a conferência pode mostrar: o banco tem views com padrão 0, mas o changeset gerado continua exigindo o campo (validate_required inclui :views). As regras do banco e as do changeset são independentes; aqui, quem chama precisa mandar o valor.

Funções novas no Catalog

Na documentação: Adding Catalog functions

Os contextos são módulos dedicados que expõem e agrupam funcionalidades relacionadas. O Phoenix gera funções genéricas, como list_products e update_product, mas elas são só uma base para a lógica de negócio crescer. O guia acrescenta uma funcionalidade básica de catálogo: contar quantas vezes a página de cada produto foi vista. Num e-commerce, esse número serve para marketing, sugestões e rankings.

Daria para usar a função que já existe, algo como Catalog.update_product(product, %{views: product.views + 1}). Isso, porém, exigiria que quem chama soubesse demais sobre o catálogo, e ainda estaria sujeito a uma condição de corrida.

Trilha.pergunta(:corrida)

Intuitivamente, a gente imagina esta sequência:

  1. a pessoa 1 abre a página do produto, com 13 visualizações;
  2. a pessoa 1 grava 14;
  3. a pessoa 2 abre a página, com 14;
  4. a pessoa 2 grava 15.

Na prática, pode acontecer esta:

  1. a pessoa 1 abre a página, com 13;
  2. a pessoa 2 abre a página, com 13;
  3. a pessoa 1 grava 14;
  4. a pessoa 2 grava 14.

Com várias pessoas atualizando valores lidos antes, a contagem deixa de ser confiável.

A condição de corrida, ao vivo

A célula abaixo cria um produto e simula 100 visitas simultâneas, cada uma lendo o produto e gravando views + 1 com update_product/2. Task.async_stream/3 roda cada visita num processo separado, 20 de cada vez. Os logs de cada consulta são desligados durante a corrida (seriam 200 entradas) e religados no fim.

{:ok, contador} =
  Hello.Catalog.create_product(%{title: "Contador", description: "Para a corrida", price: 1, views: 0})

nivel = Logger.level()
Logger.configure(level: :info)

1..100
|> Task.async_stream(
  fn _visita ->
    product = Hello.Catalog.get_product!(contador.id)
    Hello.Catalog.update_product(product, %{views: product.views + 1})
  end,
  max_concurrency: 20
)
|> Stream.run()

Logger.configure(level: nivel)

Hello.Catalog.get_product!(contador.id).views

Cem visitas, e o contador fica bem abaixo de 100. O número exato muda a cada execução: depende de quantas visitas leram o mesmo valor antes de alguma gravar. Cada visita perdida é um UPDATE que gravou um número calculado com um valor velho.

A saída é pensar numa função que descreva o que se quer fazer. O guia escreve primeiro como gostaria de usá-la:

product = Catalog.inc_page_views(product)

Quem chama não tem dúvida sobre o que a função faz, e o incremento pode ficar dentro de uma operação atômica, sem corrida.

Sua vez: incrementar dentro do banco

Acrescente a função do guia à célula do Hello.Catalog, junto das outras, e avalie a célula:

def inc_page_views(%Product{} = product) do
  {1, [%Product{views: views}]} =
    from(p in Product, where: p.id == ^product.id, select: [:views])
    |> Repo.update_all(inc: [views: 1])

  put_in(product.views, views)
end
Trilha.pergunta(:update_all)

A função monta uma consulta que encontra o produto pelo id e a passa a Repo.update_all/2, a função do Ecto para atualizações em lote, que também serve para alterar valores de forma atômica. inc: [views: 1] vira SET "views" = p0."views" + 1: a soma acontece dentro do banco, no mesmo comando que grava, e duas visitas simultâneas nunca leem o mesmo valor velho. O select: [:views] pede de volta o valor novo, e put_in(product.views, views) o coloca na struct que a função devolve.

Trilha.conferir(:inc_page_views)
Trilha.solucao(:inc_page_views)

Uma visita sozinha, para ver o SQL no log (se você ainda não fez o exercício, a célula dá UndefinedFunctionError):

Hello.Catalog.inc_page_views(contador)

O log mostra o UPDATE que o guia cita, com o RETURNING que traz o valor novo:

UPDATE "products" AS p0 SET "views" = p0."views" + $1 WHERE (p0."id" = $2) RETURNING p0."views"

Os parâmetros, no fim da linha do log, são o incremento (1) e o id do produto.

Agora a mesma corrida de 100 visitas, usando inc_page_views/1:

antes = Hello.Catalog.get_product!(contador.id).views

# A primeira visita roda aqui mesmo: se a função ainda não existir, o erro
# aparece nesta célula, e não em 99 processos ao mesmo tempo.
Hello.Catalog.inc_page_views(contador)

nivel = Logger.level()
Logger.configure(level: :info)

2..100
|> Task.async_stream(fn _visita -> Hello.Catalog.inc_page_views(contador) end, max_concurrency: 20)
|> Stream.run()

Logger.configure(level: nivel)

Hello.Catalog.get_product!(contador.id).views - antes

Exatamente 100. Repare que todas as visitas recebem a mesma struct contador, com o views antigo: não importa, porque a função só usa o id.

Sua vez: contar as visitas

Com a função pronta, falta usá-la. Na célula do HelloWeb.ProductController, mude a action show como o guia faz: o produto buscado passa por Catalog.inc_page_views/1, que devolve o produto atualizado, e é esse que vai para o template.

def show(conn, %{"id" => id}) do
  product =
    id
    |> Catalog.get_product!()
    |> Catalog.inc_page_views()

  render(conn, :show, product: product)
end
Trilha.conferir(:show)
Trilha.solucao(:show)

Atualize a página de um produto algumas vezes, no navegador ou com as células abaixo, e veja Views subir:

Trilha.navegar("ana", :get, "/products/1")
Trilha.navegar("ana", :get, "/products/1")

No log de cada visita aparece o mesmo UPDATE ... SET "views" = p0."views" + $1 ... RETURNING p0."views". O rastreio mostra as duas funções do contexto na mesma action:

Trilha.rastrear(fn -> Trilha.visitar("ana", :get, "/products/1") end)

Projetar com contextos dá uma base sólida para a aplicação crescer. APIs pequenas e bem definidas, que expõem a intenção do sistema, deixam o código mais fácil de manter e de reaproveitar. O próximo capítulo, no notebook 09, trata de relacionamentos dentro de um contexto.

Sem roteiro: a vitrine do Brechó vira um contexto

Agora o mesmo assunto sem passo a passo. No notebook 07, o Brechó, o mini-marketplace da trilha, ganhou a tabela produtos, e o ProdutoController falava direto com o Repo. É o código que este capítulo ensina a evitar. A tarefa é passar o Brechó para um contexto, Hello.Vitrine, e aproveitar para resolver um problema que tem a mesma forma do contador de visitas: duas pessoas comprando a mesma peça ao mesmo tempo. Num brechó, cada peça é única.

O Brechó até aqui

As células abaixo são o estado do Brechó no fim do notebook 07: a migration, o schema Hello.Produto e as rotas e o controller de /produtos.

priv/repo/migrations/20250320000000_create_produtos.exs

defmodule Hello.Repo.Migrations.CreateProdutos do
  use Ecto.Migration

  def change do
    create table(:produtos) do
      add :nome, :string, null: false
      add :preco_centavos, :integer, null: false
      add :vendido, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)

lib/hello/produto.ex

defmodule Hello.Produto do
  use Ecto.Schema
  import Ecto.Changeset

  schema "produtos" do
    field :nome, :string
    field :preco_centavos, :integer
    field :vendido, :boolean, default: false

    timestamps(type: :utc_datetime)
  end

  def changeset(produto, attrs) do
    produto
    |> cast(attrs, [:nome, :preco_centavos, :vendido])
    |> validate_required([:nome, :preco_centavos])
    |> validate_number(:preco_centavos, greater_than: 0)
  end
end

O que fazer

  1. O contexto Hello.Vitrine, com o schema movido para dentro dele, como o guia sugere ao falar de renomear: Hello.Produto vira Hello.Vitrine.Produto, na mesma tabela. Aproveite e tire :vendido do cast: quem decide se uma peça foi vendida é o contexto, não um formulário.
  2. As funções do contexto: listar_disponiveis/0 (só as peças não vendidas, da mais barata à mais cara), buscar_produto!/1 e cadastrar_produto/1 (devolve {:ok, produto} ou {:error, changeset}).
  3. vender/1: recebe um produto e o marca como vendido, devolvendo {:ok, produto} com vendido: true. Se a peça já tiver sido vendida, devolve {:error, :ja_vendido}. Vinte vendas simultâneas da mesma peça devem resultar em exatamente uma venda. Pense no inc_page_views/1: a verificação e a mudança precisam estar no mesmo UPDATE.
  4. A rota POST /produtos/:id/vender, no router abaixo, e a action vender no controller: vende a peça e redireciona para /produtos, com uma flash :info quando dá certo e uma flash :error quando a peça já foi vendida.
  5. O ProdutoController reescrito para falar só com a Hello.Vitrine, sem Hello.Repo. GET /produtos e POST /produtos continuam respondendo como no 07. Na página da vitrine, cada peça ganha um link "Vender" com method="post".

Escreva o contexto e o schema aqui. A célula fica antes do router e do controller para que, ao avaliar o notebook de cima para baixo, o controller já encontre a Vitrine compilada.

# lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex

lib/hello_web/router.ex: o router com as rotas do Brechó do notebook 07. Acrescente a de venda.

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :home
    resources "/products", ProductController

    get "/produtos", ProdutoController, :index
    post "/produtos", ProdutoController, :create
  end
end

Os avisos redefining module HelloWeb.Router e HelloWeb.ProdutoController.init/1 is undefined são esperados, como no router dos produtos.

lib/hello_web/controllers/produto_controller.ex e produto_html.ex: como ficaram no notebook 07, falando direto com o Repo. Reescreva.

defmodule HelloWeb.ProdutoController do
  use HelloWeb, :controller

  import Ecto.Query

  alias Hello.{Produto, Repo}

  def index(conn, _params) do
    produtos =
      Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)

    render(conn, :index, produtos: produtos)
  end

  def create(conn, %{"produto" => attrs}) do
    case Repo.insert(Produto.changeset(%Produto{}, attrs)) do
      {:ok, produto} ->
        conn |> put_status(201) |> json(%{id: produto.id})

      {:error, changeset} ->
        erros = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
        conn |> put_status(422) |> json(%{errors: erros})
    end
  end
end

defmodule HelloWeb.ProdutoHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h2>Vitrine do Brechó</h2>
      <ul>
        <li :for={produto <- @produtos}>{produto.nome} · {produto.preco_centavos} centavos</li>
      </ul>
    </Layouts.app>
    """
  end
end

Para testar à mão, cadastre algumas peças e venda uma, no navegador (http://localhost:4010/produtos) ou com células como estas. Antes de a rota de venda existir, a segunda célula mostra um Phoenix.Router.NoRouteError.

for {nome, preco} <- [{"Jaqueta jeans", 8000}, {"Vestido floral", 4500}] do
  Trilha.requisicao(:post, "/produtos", %{"produto" => %{"nome" => nome, "preco_centavos" => to_string(preco)}})
end

Trilha.navegar("ana", :get, "/produtos")
Trilha.navegar("ana", :post, "/produtos/1/vender")

A conferência cria e apaga as próprias peças, vende uma delas 20 vezes ao mesmo tempo e rastreia as requisições para ver se o controller chama o Repo.

Trilha.conferir(:brecho)
Trilha.solucao(:brecho)

No seu projeto

Repita o guia em um projeto gerado por mix phx.new hello. A correspondência entre as células e os arquivos e comandos é esta:

Neste notebook No projeto
As células geradas (migration, schema, contexto, web) mix phx.gen.html Catalog Product products title:string description:string price:decimal views:integer
Célula da migration + Trilha.migracao/2 priv/repo/migrations/*_create_products.exs
Ecto.Migrator.run(..., :up, all: true) mix ecto.migrate
Célula nova do router A linha resources "/products", ProductController em lib/hello_web/router.ex
Phoenix.Router.ConsoleFormatter.format/1 mix phx.routes
HelloWeb.ProductHTML, uma função por template product_html.ex com embed_templates "product_html/*" e os arquivos .heex
Trilha.navegar/4 O navegador em http://localhost:4000/products
Trilha.rastrear/1 Os logs Processing with ... e QUERY OK no terminal do mix phx.server
Células que chamam Hello.Catalog direto iex -S mix, com alias Hello.Catalog
Avaliar de novo a célula do contexto Salvar o arquivo (o mix phx.server recompila) ou recompile() no IEx

Roteiro:

  1. Rode o mix phx.gen.html acima e leia a lista de arquivos. Abra cada um e compare com as células deste notebook.
  2. Faça a mudança da migration antes do mix ecto.migrate. Se esquecer, mix ecto.rollback, ajuste e migre de novo.
  3. Acrescente a rota, rode mix phx.routes e suba o servidor com mix phx.server. Crie, edite e apague produtos em http://localhost:4000/products.
  4. No iex -S mix, chame Hello.Catalog.list_products() e Hello.Catalog.create_product(%{}) e compare com o que o controller faz.
  5. Acrescente inc_page_views/1 e mude a action show. Atualize a página de um produto e procure o UPDATE ... RETURNING no terminal.
  6. Rode a corrida no IEx, com Task.async_stream/3, primeiro com update_product/2 e depois com inc_page_views/1.
  7. Abra test/hello/catalog_test.exs: os testes gerados chamam o contexto do mesmo jeito que o controller. O módulo de testes da trilha volta a eles.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que é um contexto, por que o controller não chama o Repo, e por que inc_page_views/1 é mais do que um atalho para update_product/2. Depois compare com os pontos abaixo.

  • Um contexto é um módulo comum que concentra o acesso aos dados e as regras de uma parte do sistema. É a API pública dessa parte: Hello.Catalog para o catálogo.
  • Os geradores criam o contexto (lib/hello/catalog.ex), o schema numa pasta com o nome do contexto (lib/hello/catalog/product.ex) e, no caso do phx.gen.html, o controller e os templates em lib/hello_web/.
  • O primeiro argumento dos geradores é o contexto. Sem ele, o Phoenix usa o plural do recurso. Dar nome com intenção mostra como as partes do sistema se dividem; quando a divisão ainda não está clara, um nome simples e uma mudança depois também servem.
  • O controller é a interface web: chama funções do contexto com nomes que dizem a intenção e decide a resposta (render, redirect, flash). Ele não sabe como os dados são guardados, e por isso não muda quando isso muda.
  • O schema valida e converte com changeset/2. O @doc false marca a função como detalhe interno: quem está fora do contexto passa pelo contexto.
  • Funções genéricas como update_product/2 são só o começo. Funções com propósito, como inc_page_views/1, escondem detalhes e podem garantir coisas que quem chama não conseguiria, como um incremento atômico.
  • Ler um valor e gravar um valor calculado a partir dele é uma condição de corrida quando há acessos simultâneos. A saída é fazer a conta dentro do banco, num só comando: Repo.update_all(consulta, inc: [views: 1]).

Próximo guia da trilha: In-context Relationships, no notebook 09, que acrescenta categorias ao catálogo.