Powered by AppSignal & Oban Pro

Trilha Phoenix · Ecto

07_ecto.livemd

Trilha Phoenix · Ecto

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 e
  migrations como o psql mostraria, faz requisições ao endpoint, 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` (veja a seção sobre `mix ecto.create` neste notebook) 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."
  def recomecar do
    sql("DROP SCHEMA public CASCADE")
    sql("CREATE SCHEMA public")

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

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

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

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

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

  @doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
  def relacoes do
    resultado =
      sql("""
      SELECT n.nspname AS "Schema", c.relname AS "Name",
             CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
             pg_get_userbyid(c.relowner) AS "Owner"
      FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
      WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
      ORDER BY c.relname
      """)

    if resultado.rows == [] do
      Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
    else
      Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
    end
  end

  @doc "Mostra as colunas e os índices 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)

      Kino.Markdown.new("**`\\d #{tabela}`**\n\n" <> tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> lista)
    end
  end

  @doc "Executa um SQL e mostra o resultado como tabela."
  def consultar(consulta, parametros \\ []) do
    resultado = sql(consulta, parametros)
    Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
  end

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

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

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

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

  defp celula(nil), do: ""
  defp celula(""), do: ""
  defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
  defp celula(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) do
    descartar_respostas_antigas()

    conn =
      Phoenix.ConnTest.build_conn()
      |> Phoenix.ConnTest.dispatch(@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

  @doc "Faz a requisição e mostra o status, o content-type e o corpo da resposta."
  def mostrar(metodo \\ :get, caminho, parametros \\ nil) do
    titulo = "### #{metodo |> to_string() |> String.upcase()} #{caminho}"

    corpo =
      case requisicao(metodo, caminho, parametros) do
        {:ok, conn} ->
          tipo = conn |> Plug.Conn.get_resp_header("content-type") |> Enum.join()

          "**Status** `#{conn.status}` · **content-type** `#{tipo}`\n\n" <>
            cerca(to_string(conn.resp_body), if(tipo =~ "json", do: "json", else: "html"))

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

    Kino.Markdown.new(titulo <> "\n\n" <> corpo)
  end

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

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

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

    quadro = Kino.Frame.new()

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

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

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

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

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

  defp perguntas do
    %{
      timestamps: %{
        texto:
          "o comando pediu quatro campos: `name`, `email`, `bio` e `number_of_pets`. Quantas colunas a tabela `users` vai ter depois da migration?",
        opcoes: [
          a: "Quatro, uma por campo pedido.",
          b: "Cinco: os quatro campos e uma chave primária `id`.",
          c: "Sete: os quatro campos, `id`, `inserted_at` e `updated_at`.",
          d: "Depende do banco de dados."
        ],
        correta: :c,
        explicacao:
          "A migration declara os quatro campos e chama `timestamps/1`, que acrescenta `inserted_at` e `updated_at`. A chave primária `id` não aparece na migration: `create table/1` a cria por padrão, como um inteiro gerado por uma sequência."
      },
      migrar_de_novo: %{
        texto: "a migration `CreateUsers` já rodou. O que acontece se você rodar `mix ecto.migrate` de novo?",
        opcoes: [
          a: "O Ecto tenta criar a tabela outra vez, e o PostgreSQL responde que ela já existe.",
          b: "O Ecto apaga a tabela e a cria de novo, vazia.",
          c: "Nada: o Ecto consulta a tabela `schema_migrations`, vê que essa versão já rodou e não a executa.",
          d: "O Ecto cria uma segunda tabela, `users_2`."
        ],
        correta: :c,
        explicacao:
          "Cada migration aplicada fica registrada pela sua versão na tabela `schema_migrations`, que o próprio Ecto cria na primeira vez. Antes de rodar, o Ecto compara as migrations que conhece com as versões registradas e executa só as pendentes. É isso que torna `mix ecto.migrate` seguro de repetir."
      },
      changeset_vazio: %{
        texto:
          "o `changeset/2` do `User` chama `cast` com os quatro campos e `validate_required` com os quatro. O que tem em `User.changeset(%User{}, %{})`?",
        opcoes: [
          a: "Um changeset válido sem mudanças, porque nada foi alterado.",
          b: "Um changeset inválido, com um erro `can't be blank` para cada um dos quatro campos.",
          c: "Uma exceção, porque os parâmetros estão vazios.",
          d: "Um changeset inválido com um só erro, o do primeiro campo."
        ],
        correta: :b,
        explicacao:
          "`validate_required/3` não para no primeiro problema: verifica todos os campos da lista e acumula um erro para cada um ausente. A função não levanta exceção; ela devolve o changeset com `valid?: false` e a lista `errors`. Quem decide o que fazer com isso é quem chamou."
      },
      random_key: %{
        texto:
          "os parâmetros têm os quatro campos válidos e uma chave a mais, `random_key: \"random value\"`, que o schema não conhece. O que o `cast/3` faz com ela?",
        opcoes: [
          a: "Levanta um erro dizendo que a chave não existe no schema.",
          b: "Acrescenta um erro ao changeset e o marca como inválido.",
          c: "Grava a chave junto, e o banco a recusa ao inserir.",
          d: "Descarta a chave em silêncio: só os campos da lista do `cast` entram em `changes`."
        ],
        correta: :d,
        explicacao:
          "`cast/3` só considera os campos passados na lista, e só se existirem no schema. Todo o resto é ignorado. É uma proteção: um formulário adulterado não consegue alterar campos que o código não liberou, como um `admin` ou um `preco`."
      },
      valores_string: %{
        texto:
          "um formulário envia `%{\"name\" => \"Ana\", \"number_of_pets\" => \"3\"}`, com chaves e valores em texto, como todo parâmetro que chega pela web. O que fica em `changeset.changes`?",
        opcoes: [
          a: "`%{\"name\" => \"Ana\", \"number_of_pets\" => \"3\"}`, exatamente como chegou.",
          b: "`%{name: \"Ana\", number_of_pets: 3}`: chaves como átomos e o número convertido para inteiro.",
          c: "`%{name: \"Ana\", number_of_pets: \"3\"}`: só as chaves são convertidas.",
          d: "Nada: `cast/3` só aceita chaves que são átomos."
        ],
        correta: :b,
        explicacao:
          "`cast/3` aceita chaves em átomo ou em string (mas não as duas misturadas) e converte cada valor para o tipo declarado no schema. `\"3\"` vira `3` porque `number_of_pets` é `:integer`. Um valor que não pode ser convertido, como `\"muitos\"`, vira um erro `is invalid` no changeset."
      },
      struct_sem_validacao: %{
        texto:
          "o `changeset/2` exige `name`, `email` e `bio`. O que acontece com `Repo.insert(%User{email: \"user1@example.com\"})`, sem changeset?",
        opcoes: [
          a: "Devolve `{:error, changeset}` com os erros de `name` e `bio`.",
          b: "Insere o registro, com `name` e `bio` vazios.",
          c: "Levanta uma exceção, porque o Repo só aceita changesets.",
          d: "Chama `User.changeset/2` automaticamente antes de inserir."
        ],
        correta: :b,
        explicacao:
          "As validações moram no changeset, e o changeset só roda se você o chamar. Uma struct passada direto ao Repo é inserida como está, e o banco aceita porque as colunas `name` e `bio` permitem `NULL`. Por isso dados vindos de fora (formulários, APIs) sempre passam por um changeset."
      },
      drop_conectado: %{
        texto: "o `Hello.Repo` está rodando, com 10 conexões abertas no banco `trilha_phoenix_dev`. O que acontece se você mandar apagar o banco agora?",
        opcoes: [
          a: "O banco é apagado, e as próximas consultas do Repo dão erro.",
          b: "O Ecto fecha as conexões do Repo, apaga o banco e reconecta.",
          c: "O PostgreSQL recusa: não apaga um banco com sessões abertas.",
          d: "O PostgreSQL apaga só as tabelas e mantém o banco."
        ],
        correta: :c,
        explicacao:
          "`DROP DATABASE` falha com o erro `55006 (object_in_use)` enquanto houver outras sessões conectadas ao banco. Por isso `mix ecto.drop` costuma ser rodado com o servidor parado. A alternativa é `mix ecto.drop --force-drop`, que pede ao PostgreSQL (13 ou mais novo) para derrubar as conexões antes."
      },
      rollback: %{
        texto: "a migration `AddCommentsTable` só tem a função `change/0`, com um `create table(:comments)`. O que `mix ecto.rollback` faz com ela?",
        opcoes: [
          a: "Nada, porque não existe uma função `down/0`.",
          b: "Apaga a tabela `comments`: o Ecto sabe inverter um `create table`.",
          c: "Esvazia a tabela `comments`, mas mantém a estrutura.",
          d: "Apaga o arquivo da migration."
        ],
        correta: :b,
        explicacao:
          "Em `change/0`, o Ecto registra os comandos e sabe executá-los nos dois sentidos: indo, `create table` cria; voltando, vira `drop table`. Para comandos que não têm inverso conhecido, como um `execute` com SQL livre, a migration precisa definir `up/0` e `down/0` separadamente."
      }
    }
  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
    %{
      opcional: ~S'''
      def changeset(user, attrs) do
        user
        |> cast(attrs, [:name, :email, :bio, :number_of_pets])
        |> validate_required([:name, :email, :bio])
      end
      ''',
      validacoes: ~S'''
      def changeset(user, attrs) do
        user
        |> cast(attrs, [:name, :email, :bio, :number_of_pets])
        |> validate_required([:name, :email, :bio])
        |> validate_length(:bio, min: 2)
        |> validate_length(:bio, max: 140)
        |> validate_format(:email, ~r/@/)
      end
      ''',
      consultas: ~S'''
      defmodule Hello.Consultas do
        import Ecto.Query

        alias Hello.{Repo, User}

        def emails_com_pets do
          Repo.all(
            from u in User,
              where: u.number_of_pets > 0,
              order_by: u.email,
              select: u.email
          )
        end

        def contar_emails_com(trecho) do
          Repo.one(from u in User, where: ilike(u.email, ^"%#{trecho}%"), select: count(u.id))
        end
      end
      ''',
      unico: ~S'''
      def changeset(user, attrs) do
        user
        |> cast(attrs, [:name, :email, :bio, :number_of_pets])
        |> validate_required([:name, :email, :bio])
        |> validate_length(:bio, min: 2)
        |> validate_length(:bio, max: 140)
        |> validate_format(:email, ~r/@/)
        |> unique_constraint(:email)
      end
      ''',
      comments: ~S'''
      defmodule Hello.Repo.Migrations.AddCommentsTable do
        use Ecto.Migration

        def change do
          create table(:comments) do
            add :body, :string
            add :word_count, :integer

            timestamps(type: :utc_datetime)
          end
        end
      end

      Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)
      ''',
      brecho: ~S'''
      # 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)

      # 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

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

      # No router, dentro do scope "/":
      get "/produtos", ProdutoController, :index
      post "/produtos", ProdutoController, :create

      # lib/hello_web/controllers/produto_controller.ex e produto_html.ex
      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
      '''
    }
  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
    %{
      opcional: [
        {"`User.changeset(%User{}, %{})` acusa `name`, `email` e `bio` em branco.",
         fn -> campos_com_erro(%{}, [:name, :email, :bio]) end},
        {"`number_of_pets` deixou de ser obrigatório.",
         fn ->
           if Keyword.has_key?(changeset_user(%{}).errors, :number_of_pets),
             do: {:erro, "O changeset ainda acusa `number_of_pets`. Tire o campo da lista do `validate_required`."},
             else: :ok
         end},
        {"`number_of_pets` continua sendo aceito pelo `cast`.",
         fn ->
           case changeset_user(%{"number_of_pets" => "3"}).changes do
             %{number_of_pets: 3} -> :ok
             _ -> {:erro, "O campo deve continuar na lista do `cast/3`; só sai da lista do `validate_required`."}
           end
         end}
      ],
      validacoes: [
        {"Uma `bio` de uma letra recebe o erro de tamanho mínimo 2.",
         fn -> erro_validacao(%{bio: "A"}, :bio, validation: :length, kind: :min, count: 2) end},
        {"Uma `bio` de 141 letras recebe o erro de tamanho máximo 140.",
         fn -> erro_validacao(%{bio: String.duplicate("a", 141)}, :bio, validation: :length, kind: :max, count: 140) end},
        {"Um `email` sem `@` recebe o erro de formato.",
         fn -> erro_validacao(%{email: "example.com"}, :email, validation: :format) end},
        {"Um usuário completo, sem `number_of_pets`, é válido.",
         fn ->
           cs = changeset_user(%{name: "Joe", email: "joe@example.com", bio: "An example to all"})

           if cs.valid?,
             do: :ok,
             else: {:erro, "O changeset ficou inválido: `#{inspect(cs.errors)}`. `number_of_pets` deve continuar opcional."}
         end}
      ],
      consultas: [
        {"`Hello.Consultas` define `emails_com_pets/0` e `contar_emails_com/1`.",
         fn -> exporta(Hello.Consultas, emails_com_pets: 0, contar_emails_com: 1) end},
        {"`emails_com_pets/0` devolve os e-mails de quem tem pelo menos um pet, em ordem alfabética.",
         fn ->
           esperado = coluna("SELECT email FROM users WHERE number_of_pets > 0 ORDER BY email")
           obtido = apply(Hello.Consultas, :emails_com_pets, [])

           cond do
             obtido == esperado -> :ok
             is_list(obtido) and Enum.any?(obtido, &is_struct/1) -> {:erro, "A função devolveu structs. Use `select: u.email` para devolver só os e-mails."}
             true -> {:erro, "Esperado `#{inspect(esperado)}`, veio `#{inspect(obtido, limit: 10)}`."}
           end
         end},
        {"`contar_emails_com/1` conta os e-mails que contêm o trecho, sem diferenciar maiúsculas.",
         fn ->
           Enum.reduce_while(["brecho", "EXAMPLE", "a", "nada-disso"], :ok, fn trecho, :ok ->
             [[esperado]] = sql("SELECT count(*) FROM users WHERE email ILIKE $1", ["%" <> trecho <> "%"]).rows
             obtido = apply(Hello.Consultas, :contar_emails_com, [trecho])

             if obtido == esperado,
               do: {:cont, :ok},
               else: {:halt, {:erro, "Para `#{inspect(trecho)}`, o esperado é `#{esperado}`, veio `#{inspect(obtido)}`."}}
           end)
         end}
      ],
      unico: [
        {"O índice único `users_email_index` existe.",
         fn ->
           if coluna("SELECT indexname FROM pg_indexes WHERE indexname = 'users_email_index'") == ["users_email_index"],
             do: :ok,
             else: {:erro, "Rode a célula da migration `AddUsersEmailIndex` e a do `mix ecto.migrate` logo abaixo dela."}
         end},
        {"Inserir um e-mail repetido devolve `{:error, changeset}` com o erro `has already been taken`.",
         fn ->
           params = %{name: "Repetida", email: "repetida@example.com", bio: "Duas vezes"}

           {:error, resultado} =
             apply(@repo, :transaction, [
               fn ->
                 primeiro = apply(@repo, :insert, [changeset_user(params)])
                 segundo = apply(@repo, :insert, [changeset_user(params)])
                 apply(@repo, :rollback, [{primeiro, segundo}])
               end
             ])

           case resultado do
             {{:ok, _}, {:error, %Ecto.Changeset{errors: [email: {"has already been taken", _}]}}} -> :ok
             {{:ok, _}, outro} -> {:erro, "O segundo insert devolveu `#{inspect(outro, limit: 8)}`."}
             outro -> {:erro, "O primeiro insert falhou: `#{inspect(outro, limit: 8)}`."}
           end
         end}
      ],
      comments: [
        {"A migration `Hello.Repo.Migrations.AddCommentsTable` está registrada com a versão `20250318001628`.",
         fn ->
           if {20250318001628, Hello.Repo.Migrations.AddCommentsTable} in migracoes(),
             do: :ok,
             else: {:erro, "Defina o módulo e chame `Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)` na mesma célula."}
         end},
        {"A tabela `comments` existe, com `body`, `word_count` e os timestamps.",
         fn -> colunas("comments", ~w(id body word_count inserted_at updated_at)) end},
        {"`body` é texto e `word_count` é inteiro.",
         fn ->
           tipos = sql("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'comments'").rows |> Map.new(fn [c, t] -> {c, t} end)

           if tipos["body"] == "character varying" and tipos["word_count"] == "integer",
             do: :ok,
             else: {:erro, "Os tipos estão `#{inspect(tipos)}`. Use `:string` para `body` e `:integer` para `word_count`."}
         end}
      ],
      brecho: [
        {"A tabela `produtos` existe, com `nome`, `preco_centavos`, `vendido` e os timestamps.",
         fn -> colunas("produtos", ~w(nome preco_centavos vendido inserted_at updated_at)) end},
        {"`Hello.Produto.changeset/2` exige `nome` e `preco_centavos`.",
         fn ->
           erros = changeset_produto(%{}).errors

           if Keyword.has_key?(erros, :nome) and Keyword.has_key?(erros, :preco_centavos),
             do: :ok,
             else: {:erro, "Com parâmetros vazios, os erros foram `#{inspect(erros)}`."}
         end},
        {"Um preço igual a zero é recusado.",
         fn ->
           case changeset_produto(%{"nome" => "Camisa", "preco_centavos" => "0"}).errors[:preco_centavos] do
             {_, opcoes} -> if opcoes[:validation] == :number, do: :ok, else: {:erro, "O erro de `preco_centavos` deveria vir de `validate_number/3`."}
             nil -> {:erro, "O changeset aceitou `preco_centavos` igual a 0. Use `validate_number/3` com `greater_than: 0`."}
           end
         end},
        {"Um produto novo tem `vendido` igual a `false`.",
         fn ->
           if struct(Hello.Produto).vendido == false,
             do: :ok,
             else: {:erro, "Declare o campo com `default: false` no schema."}
         end},
        {"`POST /produtos` com dados válidos responde 201 e grava o produto.",
         fn ->
           limpando(fn ->
             case requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Válido", "preco_centavos" => "4500"}}) do
               {:ok, %{status: 201}} ->
                 if coluna("SELECT nome FROM produtos WHERE nome = 'Conferência Válido' AND preco_centavos = 4500 AND NOT vendido") == ["Conferência Válido"],
                   do: :ok,
                   else: {:erro, "A resposta foi 201, mas o produto não está na tabela com preço 4500 e `vendido` falso."}

               {:ok, conn} ->
                 {:erro, "A resposta veio com status `#{conn.status}`. Os dados chegam em `params[\"produto\"]`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"`POST /produtos` com preço inválido responde 422, lista o erro e não grava nada.",
         fn ->
           limpando(fn ->
             case requisicao(:post, "/produtos", %{"produto" => %{"nome" => "Conferência Inválido", "preco_centavos" => "0"}}) do
               {:ok, %{status: 422} = conn} ->
                 cond do
                   not (to_string(conn.resp_body) =~ "preco_centavos") ->
                     {:erro, "O corpo da resposta deveria citar o campo `preco_centavos`. Veio: `#{conn.resp_body}`."}

                   coluna("SELECT nome FROM produtos WHERE nome = 'Conferência Inválido'") != [] ->
                     {:erro, "O produto inválido foi gravado."}

                   true ->
                     :ok
                 end

               {:ok, conn} ->
                 {:erro, "A resposta veio com status `#{conn.status}`. Use `put_status(422)` quando o insert devolver `{:error, changeset}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end},
        {"`GET /produtos` lista só os não vendidos, do mais barato ao mais caro.",
         fn ->
           limpando(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

             case requisicao(:get, "/produtos") do
               {:ok, %{status: 200, resp_body: corpo}} ->
                 corpo = to_string(corpo)
                 barato = :binary.match(corpo, "Conferência Barato")
                 caro = :binary.match(corpo, "Conferência Caro")

                 cond do
                   barato == :nomatch or caro == :nomatch -> {:erro, "A página não mostra os produtos não vendidos."}
                   corpo =~ "Conferência Vendido" -> {:erro, "A página mostra um produto vendido. Filtre com `where: not p.vendido`."}
                   elem(barato, 0) > elem(caro, 0) -> {:erro, "O mais caro aparece antes do mais barato. Ordene por `preco_centavos`."}
                   true -> :ok
                 end

               {:ok, conn} ->
                 {:erro, "A resposta veio com status `#{conn.status}`."}

               {:erro, erro} ->
                 {:erro, explicar(erro)}
             end
           end)
         end}
      ]
    }
  end

  defp changeset_user(params), do: apply(Hello.User, :changeset, [struct(Hello.User), params])
  defp changeset_produto(params), do: apply(Hello.Produto, :changeset, [struct(Hello.Produto), params])

  defp campos_com_erro(params, campos) do
    erros = changeset_user(params).errors

    case Enum.reject(campos, &Keyword.has_key?(erros, &1)) do
      [] -> :ok
      faltando -> {:erro, "O changeset não acusou #{inspect(faltando)}. Os erros foram `#{inspect(erros)}`."}
    end
  end

  defp erro_validacao(params, campo, esperado) do
    case changeset_user(params).errors[campo] do
      {_mensagem, opcoes} ->
        if Enum.all?(esperado, fn {chave, valor} -> opcoes[chave] == valor end),
          do: :ok,
          else: {:erro, "O erro de `#{campo}` veio com `#{inspect(opcoes)}`; o esperado inclui `#{inspect(esperado)}`."}

      nil ->
        {:erro, "O changeset não acusou erro em `#{campo}`. Falta a validação no `changeset/2`."}
    end
  end

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

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

  defp colunas(tabela, esperadas) do
    existentes = 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 o `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 limpando(teste) do
    teste.()
  after
    if coluna("SELECT table_name FROM information_schema.tables WHERE table_name = 'produtos'") != [] do
      sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
    end
  end

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

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

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

      String.ends_with?(nome, "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 o insert 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. Os dados do formulário chegam dentro de `params[\"produto\"]`."
  end

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

Como usar este notebook

Este notebook acompanha o guia Ecto da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção, e termina com o guia curto Swapping Databases. Até aqui a aplicação Hello só respondia a requisições. Agora ela ganha um banco de dados: um PostgreSQL de verdade, que precisa estar rodando na sua máquina.

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. Neste guia há uma fonte nova de informação: o log do Ecto. Cada consulta ao banco aparece na saída da célula como uma linha [debug] QUERY OK, seguida do SQL que o Ecto gerou. Leia esse SQL: é a melhor forma de entender o que cada função faz.

O guia usa três ferramentas de terminal que um notebook não tem: mix phx.gen.schema, as tarefas mix ecto.* e o psql. Cada uma ganha aqui um equivalente que chama as mesmas funções do Ecto por baixo, e a seção "No seu projeto" volta ao terminal.

A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo. Este notebook instala três pacotes a mais que os anteriores (ecto_sql, postgrex e phoenix_ecto); a primeira vez leva alguns minutos.

A aplicação Hello dentro do notebook

Um projeto gerado por mix phx.new hello já vem com Ecto e PostgreSQL. As duas primeiras células desta seção são as peças novas: a configuração do banco e o módulo Hello.Repo. As outras repetem a aplicação web do primeiro notebook, que só volta a ser usada no desafio do fim.

config/dev.exs: a configuração do banco no ambiente de desenvolvimento. Os valores padrão são os de um projeto novo: usuário postgres, senha postgres, servidor na própria máquina. 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
)

Uma diferença em relação ao projeto: lá o banco se chamaria hello_dev. Aqui ele se chama trilha_phoenix_dev, para que o notebook nunca apague nem altere o banco de um projeto hello que você tenha criado.

lib/hello/repo.ex: o repositório, a interface do Ecto com o banco. A seção "Configuração do Repo" volta a ele.

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

lib/hello_web.ex, router.ex e endpoint.ex: a parte web, como no fim do primeiro notebook. O router e o endpoint vêm antes dos controllers porque o ~p confere cada caminho no router durante a compilação.

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.HTML

      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
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
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.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/layouts.ex e lib/hello_web/controllers/: os layouts, as páginas de erro e a página inicial.

defmodule HelloWeb.Layouts do
  use Phoenix.Component

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Hello</title>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, default: %{}
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header>
      <strong>Hello</strong> · layout da aplicação
    </header>
    <main>
      {render_slot(@inner_block)}
    </main>
    """
  end
end

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

O Ecto precisa de um servidor PostgreSQL rodando e de um usuário que possa entrar com a senha configurada acima. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem do Postgrex em uma dica. Os comandos para instalar, iniciar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.

Trilha.verificar_postgres()

Com a conexão funcionando, crie o banco da trilha. Esta célula faz o mesmo que mix ecto.create, que aparece no fim do guia:

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

O resultado é :ok na primeira vez e {:error, :already_up} nas seguintes, quando o banco já existe. As duas respostas servem. Se a verificação acima falhou, esta célula também falha, com uma resposta pouco útil, {:error, "killed"}; o motivo real está no log de erro logo acima dela.

lib/hello/application.ex: em um projeto, a árvore de supervisão inicia o Hello.Repo e o HelloWeb.Endpoint quando a aplicação sobe. O Repo vem primeiro, porque o resto da aplicação depende do banco.

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

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

Por fim, comece do zero. A célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id e os resultados das consultas sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.

Trilha.recomecar()
Trilha.relacoes()

Usando o phx.gen.schema

Na documentação: Using phx.gen.schema

O Phoenix usa o Ecto para conversar com PostgreSQL, MySQL, MSSQL, SQLite3 e outros. Um projeto novo vem com o adaptador do PostgreSQL; mix phx.new aceita --database para escolher outro, ou --no-ecto para não incluir o Ecto.

A forma mais fácil de começar é gerar um schema com mix phx.gen.schema. Um schema descreve como os tipos de dados do Elixir correspondem aos de uma fonte externa, como uma tabela do banco. O guia gera um User com quatro campos:

$ mix phx.gen.schema User users name:string email:string \
bio:string number_of_pets:integer

* creating ./lib/hello/user.ex
* creating priv/repo/migrations/20170523151118_create_users.exs

Os argumentos são o nome do módulo (User), o nome da tabela (users) e os campos com seus tipos. O gerador cria dois arquivos: o schema e uma migration, o arquivo que cria a tabela no banco. O nome do arquivo da migration começa com a data e a hora em que foi gerado; esse número é a versão da migration.

Trilha.pergunta(:timestamps)

priv/repo/migrations/20250317170448_create_users.exs: a migration gerada. A versão aqui é a que o guia mostra mais adiante, na tabela schema_migrations. Como um notebook não tem arquivos, a última linha registra a versão que, no projeto, viria do nome do arquivo.

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

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
      add :bio, :string
      add :number_of_pets, :integer

      timestamps(type: :utc_datetime)
    end
  end
end

Trilha.migracao(20250317170448, Hello.Repo.Migrations.CreateUsers)

Além das colunas pedidas, a migration chama timestamps/1, que acrescenta inserted_at e updated_at. O gerador não executa a migration; ele avisa que é preciso rodar mix ecto.migrate. A célula abaixo é esse comando: Ecto.Migrator.run/4 recebe o Repo, a lista de migrations conhecidas, a direção (:up) e a opção all: true, que roda todas as pendentes.

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

O log mostra o que aconteceu:

[info] == Running 20250317170448 Hello.Repo.Migrations.CreateUsers.change/0 forward
[info] create table users
[info] == Migrated 20250317170448 in 0.0s

O guia mostra as mesmas linhas sem o número da versão; as versões recentes do Ecto passaram a incluí-lo. O valor devolvido, [20250317170448], é a lista de versões que rodaram.

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

Desta vez o log diz Migrations already up, e a lista devolvida é vazia.

Agora olhe o banco. No terminal, o guia entra no psql e usa o comando \d, que lista as tabelas e sequências. Trilha.relacoes() mostra o mesmo:

Trilha.relacoes()

São três relações. users é a tabela da migration. schema_migrations foi criada pelo próprio Ecto para registrar as migrations que já rodaram. users_id_seq é uma sequência, o contador do PostgreSQL que gera os valores da chave primária id.

\d users mostra a estrutura da tabela:

Trilha.descrever("users")

Compare com a migration:

  • id não está na migration, mas existe: create table/1 cria uma chave primária bigint por padrão, com valor padrão nextval('users_id_seq'::regclass), ou seja, o próximo número da sequência.
  • Cada campo :string virou character varying(255), um texto de até 255 caracteres. :integer virou integer.
  • timestamps(type: :utc_datetime) criou inserted_at e updated_at como timestamp(0) without time zone, sem frações de segundo e obrigatórios (not null). O PostgreSQL guarda a data sem fuso; o Ecto sabe que ela está em UTC.
  • As outras colunas aceitam valor vazio (NULL): a coluna Nullable está em branco. Guarde esse detalhe para a seção de persistência.

Configuração do Repo

Na documentação: Repo configuration

O módulo Hello.Repo é a base para trabalhar com bancos de dados em uma aplicação Phoenix. Releia a célula dele, na seção da aplicação: ele só chama use Ecto.Repo com duas opções. Isso faz três coisas:

  • traz para Hello.Repo todas as funções de consulta e escrita de Ecto.Repo (insert, all, one, update, delete e outras);
  • liga o Repo à aplicação :hello (otp_app), que é onde ele procura a própria configuração;
  • escolhe o adaptador do banco, Ecto.Adapters.Postgres.

A configuração em si fica em config/dev.exs, na célula do início. O projeto tem blocos parecidos em config/test.exs (o banco dos testes) e em config/runtime.exs (produção, com valores lidos de variáveis de ambiente). Veja a configuração completa que o Repo enxerga, já com os valores que o Ecto acrescenta:

Hello.Repo.config()

A senha aparece em texto puro: este é o ambiente de desenvolvimento, na sua máquina. show_sensitive_data_on_connection_error: true tem a mesma intenção: mostrar os dados da conexão nas mensagens de erro, o que ajuda a depurar e seria um problema em produção.

O schema

Na documentação: The schema

Os schemas do Ecto mapeiam valores do Elixir para fontes de dados externas e os dados externos de volta para estruturas do Elixir. Também podem definir relações com outros schemas: um User pode ter vários posts, e cada post pertence a um usuário. Isso aparece nos guias de contextos.

lib/hello/user.ex: o schema que o gerador criou.

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

  schema "users" do
    field :bio, :string
    field :email, :string
    field :name, :string
    field :number_of_pets, :integer

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :email, :bio, :number_of_pets])
    |> validate_required([:name, :email, :bio, :number_of_pets])
  end
end

No fundo, um schema do Ecto é uma struct do Elixir. O bloco schema diz ao Ecto como converter os campos da struct %User{} de e para a tabela users.

%Hello.User{}

A struct tem os quatro campos, o id, os timestamps e um campo __meta__, que o Ecto usa para saber de onde o dado veio. :built quer dizer que a struct foi montada no código e ainda não existe no banco.

O schema também se descreve. As funções __schema__/1 e __schema__/2 são o que o Ecto consulta para gerar o SQL:

%{
  tabela: Hello.User.__schema__(:source),
  campos: Hello.User.__schema__(:fields),
  tipo_de_number_of_pets: Hello.User.__schema__(:type, :number_of_pets),
  tipo_de_inserted_at: Hello.User.__schema__(:type, :inserted_at)
}

Converter dados de e para o banco quase nunca basta: é preciso validar o que chega. Para isso existem os changesets.

Changesets e validações

Na documentação: Changesets and validations

Um changeset define uma sequência de transformações pelas quais um dado passa antes de estar pronto para uso: conversão de tipos, validação da entrada do usuário, descarte de parâmetros que não interessam. Os repositórios do Ecto entendem changesets: recusam dados inválidos e, ao atualizar, gravam só os campos que mudaram.

A função changeset/2 gerada tem duas etapas:

  • cast/3 recebe a struct, os parâmetros externos (as mudanças propostas) e a lista dos campos que podem ser alterados. Só aceita campos que existem no schema.
  • validate_required/3 verifica se os campos da lista estão presentes no changeset que o cast/3 devolveu. O gerador marca todos como obrigatórios.

É do lado do servidor o que você faria com required e pattern em um <input>, com uma diferença: a validação do navegador pode ser desligada por quem usa a página, e a do servidor não.

Trilha.pergunta(:changeset_vazio)

O guia faz os testes no IEx. Aqui, cada célula é um passo:

changeset = Hello.User.changeset(%Hello.User{}, %{})
%{valido?: changeset.valid?, erros: changeset.errors}

Cada erro é uma tupla com a mensagem ("can't be blank") e uma lista de metadados (validation: :required) que diz qual validação falhou.

Sua vez: um campo opcional

O guia torna number_of_pets opcional. Na célula do Hello.User, tire :number_of_pets da lista do validate_required (e só dela: o campo continua no cast). Avalie a célula. No IEx, o guia chama recompile(); aqui, avaliar a célula tem o mesmo efeito.

Hello.User.changeset(%Hello.User{}, %{}).errors
Trilha.conferir(:opcional)
Trilha.solucao(:opcional)

Parâmetros que o schema não conhece

Trilha.pergunta(:random_key)
params = %{
  name: "Joe Example",
  email: "joe@example.com",
  bio: "An example to all",
  number_of_pets: 5,
  random_key: "random value"
}

changeset = Hello.User.changeset(%Hello.User{}, params)

O changeset é válido, e changes tem só os quatro campos do schema:

changeset.changes

Changesets transformam dados externos, como a entrada de um formulário ou as linhas de um CSV, em dados válidos para o sistema. Parâmetros desconhecidos são descartados, e dados que não podem ser convertidos para o tipo do schema aparecem nos erros.

Parâmetros como chegam da web

O guia usa chaves em átomo, como name:. Mas o primeiro notebook mostrou que, em uma requisição, params tem chaves e valores em string. É assim que um formulário chega ao changeset.

Trilha.pergunta(:valores_string)
Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", "number_of_pets" => "3"}).changes

O cast/3 converteu as chaves e o valor. Agora um valor que não vira inteiro:

Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", "number_of_pets" => "muitos"}).errors

number_of_pets ganhou o erro {"is invalid", [type: :integer, validation: :cast]}. Não houve exceção: o valor estranho virou um erro de validação, que pode ser mostrado ao usuário ao lado do campo.

Só uma coisa o cast/3 não aceita: chaves em átomo e em string misturadas no mesmo mapa.

Hello.User.changeset(%Hello.User{}, %{"name" => "Ana", bio: "Oi"})

A exceção é Ecto.CastError: expected params to be a map with atoms or string keys, got a map with mixed keys. Isso costuma acontecer quando o código acrescenta uma chave em átomo a um params que veio da requisição.

Sua vez: mais validações

Além de exigir campos, um changeset pode validar formato e tamanho. O guia acrescenta três validações ao changeset/2:

  • a bio precisa ter pelo menos 2 caracteres: validate_length(:bio, min: 2);
  • e no máximo 140: validate_length(:bio, max: 140);
  • o email precisa ter um @: validate_format(:email, ~r/@/).

Acrescente as três ao fim do pipeline, na célula do Hello.User. Os trechos do guia nesta parte voltam a incluir :number_of_pets no validate_required; aqui ele continua opcional, como você deixou.

Trilha.conferir(:validacoes)
Trilha.solucao(:validacoes)

Veja os erros que as validações novas produzem:

%{
  bio: Hello.User.changeset(%Hello.User{}, %{bio: "A"}).errors[:bio],
  email: Hello.User.changeset(%Hello.User{}, %{email: "example.com"}).errors[:email]
}

A mensagem de bio é "should be at least %{count} character(s)", com count: 2 nos metadados. O %{count} fica sem substituir de propósito: assim a mensagem pode ser traduzida antes de receber o número. Na hora de mostrar, Ecto.Changeset.traverse_errors/2 percorre todos os erros e aplica a função que você passar, aqui uma que substitui cada %{chave} pelo valor:

changeset = Hello.User.changeset(%Hello.User{}, %{bio: "A", email: "example.com"})

Ecto.Changeset.traverse_errors(changeset, fn {mensagem, opcoes} ->
  Enum.reduce(opcoes, mensagem, fn {chave, valor}, acc ->
    String.replace(acc, "%{#{chave}}", to_string(valor))
  end)
end)

Em um projeto, os CoreComponents gerados fazem essa substituição (com tradução, pelo Gettext) quando mostram os erros de um formulário. A documentação de Ecto.Changeset lista as outras validações.

Persistência dos dados

Na documentação: Data persistence

Os repositórios do Ecto são a interface com um sistema de armazenamento, seja um banco como o PostgreSQL, seja um serviço externo. O Repo cuida dos detalhes: a comunicação com o adaptador, o pool de conexões e a tradução de violações de restrições do banco em erros. Quem chama só se preocupa em buscar e gravar dados.

Trilha.pergunta(:struct_sem_validacao)
Hello.Repo.insert(%Hello.User{email: "user1@example.com"})
Hello.Repo.insert(%Hello.User{email: "user2@example.com"})

Leia o log de cada inserção. Ele mostra o SQL que o Ecto gerou e os valores enviados separadamente, como parâmetros ($1, $2, $3):

[debug] QUERY OK source="users" db=0.5ms queue=0.6ms idle=112.6ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user1@example.com", ~U[...], ~U[...]]

Só entram as colunas com valor: email e os timestamps, que o Ecto preencheu sozinho. O RETURNING "id" pede ao banco o id gerado pela sequência, que volta na struct. O __meta__ agora diz :loaded: a struct corresponde a uma linha do banco. A linha ↳ abaixo do SQL aponta o código que fez a consulta, por causa do stacktrace: true da configuração.

Com um changeset, o Repo valida antes. Se o changeset é inválido, nada vai ao banco e a resposta é {:error, changeset}:

Hello.Repo.insert(Hello.User.changeset(%Hello.User{}, %{email: "user3@example.com"}))

Não há log de consulta, e o changeset devolvido tem action: :insert, que registra qual operação foi tentada.

Lendo os dados

Repo.all/1 recebe uma fonte de dados, aqui o schema User, e a traduz para uma consulta SQL. Depois converte cada linha em uma struct %User{}:

Hello.Repo.all(Hello.User)

Para consultas mais elaboradas, o Ecto tem uma linguagem própria, a Ecto.Query, com proteção contra injeção de SQL e otimização em tempo de compilação. import Ecto.Query traz a macro from/2. As células de consulta repetem o import para poderem ser avaliadas sozinhas.

import Ecto.Query

Hello.Repo.all(from u in Hello.User, select: u.email)

O SQL gerado é SELECT u0."email" FROM "users" AS u0: só a coluna pedida. Repo.one/2 busca um único resultado; aqui, a quantidade de usuários cujo e-mail contém 1:

import Ecto.Query

Hello.Repo.one(from u in Hello.User, where: ilike(u.email, "%1%"), select: count(u.id))

ilike é a comparação do PostgreSQL que ignora maiúsculas e minúsculas. O select pode montar qualquer estrutura, como um mapa de id para e-mail:

import Ecto.Query

Hello.Repo.all(from u in Hello.User, select: %{u.id => u.email})

Atualizando e apagando

Além de inserir, Repo.update/2 e Repo.delete/2 alteram e apagam um registro. update recebe um changeset; para mudanças feitas pelo próprio código, sem validação, Ecto.Changeset.change/2 monta um:

user = Hello.Repo.get!(Hello.User, 1)

Hello.Repo.update(Ecto.Changeset.change(user, name: "User One"))

O UPDATE do log só tem as colunas que mudaram: name e updated_at.

user = Hello.Repo.get!(Hello.User, 2)

Hello.Repo.delete(user)

Operações em lote

Repo.insert_all/3, Repo.update_all/3 e Repo.delete_all/2 agem sobre várias linhas com um só comando SQL. Elas não passam pelo schema nem por changesets: não validam e não preenchem os timestamps.

Hello.Repo.insert_all(Hello.User, [%{email: "sem-data@example.com"}])

A exceção vem do próprio PostgreSQL: null value in column "inserted_at" of relation "users" violates not-null constraint. Os timestamps são obrigatórios na tabela, e insert_all não os preenche. Com eles, funciona. A célula abaixo insere os usuários dos próximos exercícios:

agora = DateTime.utc_now(:second)

Hello.Repo.insert_all(Hello.User, [
  %{name: "Ana", email: "ana@brecho.com", bio: "Vende livros", number_of_pets: 2, inserted_at: agora, updated_at: agora},
  %{name: "Bia", email: "bia@example.com", bio: "Compra discos", number_of_pets: 0, inserted_at: agora, updated_at: agora},
  %{name: "Caio", email: "caio@brecho.com", bio: "Vende roupas", number_of_pets: 1, inserted_at: agora, updated_at: agora},
  %{name: "Duda", email: "duda@example.com", bio: "Compra de tudo", inserted_at: agora, updated_at: agora},
  %{name: "Edu", email: "edu@Example.com", bio: "Só olha", number_of_pets: 3, inserted_at: agora, updated_at: agora}
])

O resultado {5, nil} é a quantidade de linhas inseridas. update_all recebe uma consulta e o que mudar; aqui, quem não informou pets passa a ter zero:

import Ecto.Query

from(u in Hello.User, where: is_nil(u.number_of_pets))
|> Hello.Repo.update_all(set: [number_of_pets: 0])

Sua vez: consultas

Escreva o módulo lib/hello/consultas.ex com duas funções:

  1. emails_com_pets/0: os e-mails dos usuários com pelo menos um pet, em ordem alfabética. Use where, order_by e select.
  2. contar_emails_com/1: recebe um trecho de texto e devolve quantos usuários têm esse trecho no e-mail, sem diferenciar maiúsculas e minúsculas.

Na segunda, o trecho é um valor de fora da consulta. Para usá-lo dentro de from, escreva ^ antes da expressão: ilike(u.email, ^"%#{trecho}%"). O ^ diz ao Ecto que aquilo é um valor a enviar como parâmetro, e não código da consulta. É isso que protege contra injeção de SQL.

defmodule Hello.Consultas do
  import Ecto.Query

  # Defina aqui emails_com_pets/0 e contar_emails_com/1.
end
Trilha.conferir(:consultas)
Trilha.solucao(:consultas)

Erro como conteúdo: um e-mail repetido

Nada impede, até agora, dois usuários com o mesmo e-mail. O lugar certo para essa regra é o banco: um índice único. A migration abaixo o cria.

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

  def change do
    create unique_index(:users, [:email])
  end
end

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

O índice se chama users_email_index. Se a migration falhar com could not create unique index, o banco já tem e-mails repetidos, provavelmente porque alguma célula de inserção foi avaliada duas vezes. Avalie Trilha.recomecar() e refaça as células desde a migration CreateUsers.

Agora tente inserir duas vezes o mesmo e-mail, por um changeset válido:

params = %{name: "Ana", email: "ana@brecho.com", bio: "Outra Ana"}

Hello.Repo.insert(Hello.User.changeset(%Hello.User{}, params))

O log mostra QUERY ERROR, e a célula termina em Ecto.ConstraintError. O changeset era válido: quem recusou foi o banco. A mensagem da exceção explica o que fazer: chamar unique_constraint/3 no changeset.

Sua vez: a restrição no changeset

Acrescente unique_constraint(:email) ao fim do changeset/2 do Hello.User e avalie a célula. Depois avalie de novo a célula acima: em vez da exceção, o resultado é {:error, changeset} com o erro email: {"has already been taken", ...}, que pode ir para o formulário como qualquer outro.

unique_constraint/2 não consulta o banco antes de inserir. Ela avisa ao Ecto que, se o banco recusar o insert pelo índice users_email_index, a recusa deve virar um erro no changeset.

Trilha.conferir(:unico)
Trilha.solucao(:unico)

O guia lembra que o Ecto faz muito mais, e que a documentação do Ecto conta o resto. Os guias de modelagem de dados, os próximos da trilha, mostram como esconder esse acesso ao banco atrás de módulos que agrupam funcionalidades relacionadas.

Tarefas do Mix

Na documentação: Mix tasks

O Ecto vem com tarefas do Mix para administrar o banco. No notebook, cada uma é a função do Ecto que a tarefa chama.

mix ecto.create

Na documentação: mix ecto.create

Cria o banco configurado no Repo. Você já a usou no começo:

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

O guia lista os erros mais comuns, que vêm do PostgreSQL: o usuário postgres não existe, não pode entrar (LOGIN), não pode criar bancos (CREATEDB), ou usa outra senha. As correções são comandos SQL no psql, como administrador:

CREATE ROLE postgres LOGIN CREATEDB;
ALTER ROLE postgres LOGIN;
ALTER ROLE postgres CREATEDB;

As mensagens mudaram um pouco desde que o guia foi escrito. Veja duas reais, com uma senha errada e um usuário que não existe:

Trilha.verificar_postgres(password: "senha-errada")
Trilha.verificar_postgres(username: "usuario_que_nao_existe")

As duas dão 28P01 (invalid_password) password authentication failed. Quando o servidor pede senha, o PostgreSQL não conta se o usuário existe; a mensagem role "postgres" does not exist do guia aparece só em servidores que não pedem senha. Em algumas distribuições Linux, como o Fedora, o padrão é outro método, ident, e a mensagem é Ident authentication failed. O LEIA-ME.md mostra como resolver.

Se a aplicação tiver outro Repo, mix ecto.create -r OurCustom.Repo cria o banco dele. No notebook, seria storage_up(OurCustom.Repo.config()).

mix ecto.drop

Na documentação: mix ecto.drop

Apaga o banco do Repo, sem pedir confirmação. Cuidado com ela.

Trilha.pergunta(:drop_conectado)
Ecto.Adapters.Postgres.storage_down(Hello.Repo.config())

A resposta é {:error, "ERROR 55006 (object_in_use) database \"trilha_phoenix_dev\" is being accessed by other users\n\nThere are 10 other sessions using the database."}. As 10 sessões são o pool de conexões do Hello.Repo (pool_size: 10). No terminal, mix ecto.drop costuma rodar com o servidor parado, e o problema não aparece. Aqui, pare o Repo primeiro:

Kino.terminate_child(Process.whereis(Hello.Repo))
Ecto.Adapters.Postgres.storage_down(Hello.Repo.config())

:ok: o banco não existe mais. Recrie-o e ligue o Repo de novo:

:ok = Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
{:ok, _} = Kino.start_child(Hello.Repo)

Trilha.relacoes()

O banco está vazio. Como a estrutura inteira está nas migrations, um comando a reconstrói. Os dados não voltam: migrations guardam a estrutura, não o conteúdo.

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

Existe também mix ecto.drop --force-drop, que pede ao PostgreSQL (versão 13 ou mais nova) para derrubar as conexões antes de apagar. No adaptador, é a opção force_drop: true de storage_down/1.

mix ecto.gen.migration

Na documentação: mix ecto.gen.migration

Migrations são uma forma programática e repetível de alterar a estrutura do banco. Os geradores do Phoenix as criam junto com schemas e contextos; para escrever uma do zero, existe mix ecto.gen.migration, que recebe um nome em snake_case descrevendo a mudança:

$ mix ecto.gen.migration add_comments_table
* creating priv/repo/migrations
* creating priv/repo/migrations/20250318001628_add_comments_table.exs

O arquivo gerado tem um módulo com uma função change/0 vazia:

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

  def change do
  end
end

change/0 serve para os dois sentidos: o Ecto descobre o que fazer quando a migration avança e quando é desfeita.

Sua vez: a tabela de comentários

Complete a migration do guia: uma tabela comments com uma coluna body (:string), uma coluna word_count (:integer) e os timestamps com type: :utc_datetime. A última linha da célula registra a versão.

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

  def change do
  end
end

Trilha.migracao(20250318001628, Hello.Repo.Migrations.AddCommentsTable)

A referência das funções de migration está na documentação de Ecto.Migration. Para alterar uma tabela que já existe, a função é alter/2.

mix ecto.migrate

Na documentação: mix ecto.migrate

Com a migration pronta, rode as pendentes:

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

Na primeira execução, o ecto.migrate cria a tabela schema_migrations, que guarda a versão (a parte numérica do nome do arquivo) de cada migration aplicada:

Trilha.consultar("SELECT * FROM schema_migrations ORDER BY version")

São três linhas, uma a mais que no guia: a migration do índice único, que este notebook acrescentou.

Por padrão, mix ecto.migrate roda todas as pendentes. Há opções para controlar quantas:

No terminal No Ecto.Migrator.run/4 O que faz
mix ecto.migrate all: true todas as pendentes
mix ecto.migrate -n 2 step: 2 as próximas duas
mix ecto.migrate --step 2 step: 2 o mesmo que -n
mix ecto.migrate --to 20250317170448 to: 20250317170448 todas até essa versão, inclusive

mix ecto.rollback

Na documentação: mix ecto.rollback

mix ecto.rollback desfaz a última migration aplicada. É o espelho do ecto.migrate.

Trilha.pergunta(:rollback)

A direção agora é :down, e step: 1 é o padrão do mix ecto.rollback: desfazer só a última.

Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :down, step: 1)

O log diz == Running 20250318001628 Hello.Repo.Migrations.AddCommentsTable.change/0 backward e drop table comments. A linha da versão saiu de schema_migrations:

Trilha.consultar("SELECT * FROM schema_migrations ORDER BY version")

ecto.rollback aceita as mesmas opções que ecto.migrate (-n, --step, --to). Rode a migration de novo, agora só um passo:

Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, step: 1)
Trilha.relacoes()

Trocando de banco de dados

Na documentação: Swapping Databases

Um projeto Phoenix usa PostgreSQL por padrão, mas o Ecto tem adaptadores para outros bancos. O guia Swapping Databases mostra a troca nos dois momentos possíveis.

Em um projeto novo (Using phx.new), basta escolher na criação: mix phx.new hello_phoenix --database mysql. As opções são postgres (o padrão), mysql, mssql e sqlite3. O gerador configura as dependências e os arquivos.

Em um projeto existente (Within an existing application), são três mudanças:

  1. no mix.exs, trocar a dependência {:postgrex, ...} pelo driver do outro banco, como {:myxql, ">= 0.0.0"};
  2. em config/dev.exs (e em config/test.exs e config/runtime.exs), ajustar usuário, senha e banco, por exemplo username: "root" e password: "" no MySQL;
  3. em lib/hello/repo.ex, trocar adapter: Ecto.Adapters.Postgres por Ecto.Adapters.MyXQL.

Depois, mix deps.get, mix ecto.create e mix ecto.migrate. O exemplo de mix.exs do guia ainda mostra {:phoenix, "~> 1.4.0"}, de uma versão antiga; o que importa nele são as linhas do Ecto.

O adaptador é a única peça do Repo que conhece o banco:

Hello.Repo.__adapter__()

Schemas, changesets e a maior parte das consultas continuam iguais depois da troca. As exceções são recursos de um banco específico: o ilike usado neste notebook, por exemplo, existe no PostgreSQL e não no MySQL.

Outras opções (Other options): o Erlang traz armazenamentos chave-valor próprios, o ETS, em memória, e o DETS, em disco. O ETS pode ser usado pelo Ecto com o adaptador etso. Os guias do Phoenix não tratam dessas opções.

Sem roteiro: os produtos do Brechó

Agora o mesmo assunto sem instruções passo a passo. O Brechó, o mini-marketplace da trilha, passa a guardar os produtos no banco. Faça:

  1. Uma migration que crie a tabela produtos, com nome (texto), preco_centavos (inteiro), vendido (booleano, padrão false) e os timestamps. Registre-a com a versão 20250320000000 e rode o mix ecto.migrate. Preço em centavos, inteiro, evita os erros de arredondamento dos números com vírgula.
  2. O schema Hello.Produto, com um changeset/2 que exija nome e preco_centavos e só aceite preço maior que zero (validate_number/3). Um produto novo deve ter vendido igual a false.
  3. GET /produtos: uma página que lista os produtos não vendidos, do mais barato ao mais caro, buscando no banco.
  4. POST /produtos: recebe os campos em params["produto"], como um formulário do Phoenix envia (produto[nome], produto[preco_centavos]), insere pelo changeset e responde em JSON: status 201 com o id quando dá certo, ou 422 (Unprocessable Entity) com os erros de traverse_errors/2 quando não dá.

Use as células abaixo e edite a do router.

# priv/repo/migrations/20250320000000_create_produtos.exs
# lib/hello/produto.ex
# lib/hello_web/controllers/produto_controller.ex e produto_html.ex

Dica: o POST pode ser testado assim, sem o navegador:

Trilha.mostrar(:post, "/produtos", %{"produto" => %{"nome" => "Jaqueta jeans", "preco_centavos" => "4500"}})
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
Célula config/dev.exs config/dev.exs, bloco config :hello, Hello.Repo
Célula do Hello.Repo lib/hello/repo.ex
Célula que inicia o Repo e o endpoint lib/hello/application.ex e mix phx.server
Células das migrations + Trilha.migracao/2 Arquivos em priv/repo/migrations/, com a versão no nome
Ecto.Adapters.Postgres.storage_up/1 mix ecto.create
Ecto.Adapters.Postgres.storage_down/1 mix ecto.drop
Ecto.Migrator.run(..., :up, all: true) mix ecto.migrate
Ecto.Migrator.run(..., :down, step: 1) mix ecto.rollback
Trilha.relacoes() e Trilha.descrever("users") psql -U postgres hello_dev, depois \d e \d users
Células com Hello.Repo e Hello.User iex -S mix, com alias Hello.{Repo, User}
Avaliar de novo a célula do schema recompile() no IEx
Trilha.recomecar() mix ecto.reset (apaga, cria, migra e roda os seeds)

Roteiro:

  1. Rode mix ecto.create no projeto. Se der erro, compare a mensagem com as desta lição.
  2. Rode mix phx.gen.schema User users name:string email:string bio:string number_of_pets:integer, leia os dois arquivos gerados e rode mix ecto.migrate. Abra psql -U postgres hello_dev e use \d e \d users.
  3. No iex -S mix, refaça a seção de changesets, usando recompile() depois de cada mudança no schema.
  4. Insira usuários, consulte-os e observe o log [debug] QUERY OK no IEx.
  5. Gere a migration dos comentários com mix ecto.gen.migration add_comments_table, rode mix ecto.migrate, depois mix ecto.rollback, e confira schema_migrations no psql.
  6. Tente mix ecto.drop com o mix phx.server rodando em outro terminal e leia o erro.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que são o Repo, o schema, o changeset e a migration, e o que acontece entre Repo.insert(changeset) e a linha aparecer na tabela. Depois compare com os pontos abaixo.

  • O Repo é a interface com o banco: consultas, escritas, pool de conexões. Ele lê a configuração da aplicação (otp_app) e fala com o banco pelo adaptador.
  • A migration altera a estrutura do banco. Cada uma tem uma versão, e a tabela schema_migrations registra as que já rodaram. change/0 sabe ser desfeita; ecto.migrate avança e ecto.rollback volta.
  • O schema é uma struct que descreve como os campos correspondem às colunas de uma tabela. create table cria o id; timestamps cria inserted_at e updated_at.
  • O changeset filtra (cast/3 só aceita os campos da lista), converte tipos ("3" vira 3; "muitos" vira o erro is invalid) e valida. Os erros ficam no changeset, não viram exceção.
  • Repo.insert com um changeset inválido devolve {:error, changeset} sem tocar no banco. Com uma struct, insere sem validar.
  • Restrições do banco, como um índice único, são a última barreira. unique_constraint/2 converte a recusa do banco em erro do changeset.
  • insert_all, update_all e delete_all agem em lote, com um só SQL, sem changesets nem timestamps automáticos.
  • Trocar de banco é trocar a dependência, o adaptador e a configuração. Schemas e changesets não mudam.

Próximo guia da trilha: Contexts, o primeiro dos guias de modelagem de dados, que organiza o acesso ao Repo em módulos com uma API própria.