CRUD
Salvando dados com Ecto
Read time: 14 minutes
Esta aula é uma continuação direta da aula anterior
git clone https://github.com/adopt-liveview/lineup.git --branch cleanup-done.
Finalmente vamos começar a implementar nosso CRUD (Create-Read-Update-Delete). Atualmente nosso projeto já tem instalado tanto LiveView quanto Ecto, então vamos focar em como colocar isso em prática. Nesta aula vamos aprender como persistir nosso ticket no banco de dados.
#Conceitos importantes de Ecto
Antes de começarmos código novo, vamos explorar um pouco do que o projeto Phoenix já instalou para você e, ao mesmo tempo, conversar sobre os padrões do projeto.
#Introduzindo o Repo
Se você for até o arquivo lib/lineup/repo.ex, verá o seguinte código:
defmodule Lineup.Repo do
use Ecto.Repo,
otp_app: :lineup,
adapter: Ecto.Adapters.SQLite3
end
O Ecto usa um Design Pattern chamado Repository para acessar o banco de dados. A regra é simples: se você pretende executar uma query, vai usar este módulo. Sempre que o banco de dados precisar ser acessado você verá algo como Repo.insert() ou Repo.one().
O Phoenix automaticamente gerou este módulo Lineup.Repo. A nomenclatura será sempre no formato SeuProjeto.Repo. Dentro dele o use Ecto.Repo prepara o módulo com funções como Repo.insert e as opções passadas definem a configuração do nosso Repo. A opção otp_app contém o nome do nosso projeto Mix, :lineup, e como adaptador usamos Ecto.Adapters.SQLite3.
#Migrando o banco de dados
Para gerenciar modificações no banco de dados, o Ecto usa o padrão de projeto chamado schema migrations. A lógica de migrations é simples: sempre que você precisar modificar a estrutura do seu banco de dados, você gera uma migration que instrui o Ecto sobre o que precisa ser feito.
Vamos criar sua primeira migration: queremos criar a tabela de tickets. Usando seu terminal execute mix ecto.gen.migration create_tickets. O resultado será algo como:
* creating priv/repo/migrations/20260428160027_create_tickets.exs
Não se preocupe se o nome não for exatamente igual. As migrations possuem um timestamp no início do seu nome para deixar clara a ordem em que devem ser executadas. Neste momento sua migration deve estar com um código similar ao seguinte:
defmodule Lineup.Repo.Migrations.CreateTickets do
use Ecto.Migration
def change do
end
end
Vamos modificá-la para:
defmodule Lineup.Repo.Migrations.CreateTickets do
use Ecto.Migration
def change do
create table(:tickets) do
add :called_at, :utc_datetime
timestamps(type: :utc_datetime)
end
end
end
Dentro do nosso módulo devemos ter um método change/0. Este método é usado para especificar o que mudou no seu banco de dados. O módulo Ecto.Migration que importamos no topo da nossa migration contém esta e outras funções DDL (Data Definition Language) preparadas para operações comuns de modificar a estrutura do banco.
Dentro do change/0 podemos usar create/2 para especificar que estamos criando algo, table/2 para indicar que estamos criando uma nova tabela chamada tickets e add/3 para definir as colunas dentro desta tabela.
Quando sua migration estiver pronta e salva, execute mix ecto.migrate para rodá-la:
$ mix ecto.migrate
08:56:48.950 [info] == Running 20260428160027 Lineup.Repo.Migrations.CreateTickets.change/0 forward
08:56:48.952 [info] create table tickets
08:56:48.955 [info] == Migrated 20260428160027 in 0.0s
Como desfazer uma migration?
mix ecto.rollback para desfazer as migrations aplicadas na última vez que você executou mix ecto.migrate (mesmo que tenham sido mais de uma). Se você estiver curioso sobre como o Ecto sabe desfazer, a resposta é bem simples: se sua migration tem um método
create/2 com table/2 ele sabe que o inverso disso é apagar uma tabela. Por isso podemos criar uma migration apenas com a função change/0 ao invés de up e down como em outros frameworks, apesar de que o Ecto opcionalmente aceita esses callbacks se você tiver alguma migration específica do seu banco de dados atual.
#Criando nosso Ecto.Schema
Crie lib/lineup/queue/ticket.ex. Talvez você precise criar a pasta queue também. Escreva assim:
defmodule Lineup.Queue.Ticket do
use Ecto.Schema
import Ecto.Changeset
schema "tickets" do
field :called_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(ticket, attrs) do
ticket
|> cast(attrs, [:called_at])
|> validate_required([:called_at])
end
end
Anteriormente usamos um embedded_schema no curso de fundamentos deste site, pois ele é útil quando você não pretende trabalhar com um banco de dados. Para fazer este schema funcionar com um banco, a modificação necessária é muito simples! Usamos o macro schema/2 que recebe o nome da tabela como primeiro argumento para que, quando usarmos nosso Repo, ele saiba de onde ler/escrever os dados.
#Seu primeiro módulo de contexto
Como mencionado anteriormente, o módulo principal de lógica de negócio do nosso sistema de filas será Lineup.Queue, por isso nosso módulo Ticket vive em Lineup.Queue.Ticket. No Phoenix, chamamos de Context Modules os módulos que encapsulam funções que gerenciam uma parte da lógica de negócio da nossa aplicação. O contexto Queue é responsável por gerenciar nossos tickets. Se tivéssemos um contexto chamado Accounts, ele seria responsável por gerenciar contas de usuário. Cada contexto pode ter zero ou mais schemas e geralmente a nomenclatura será SeuProjeto.SeuContexto e SeuProjeto.SeuContexto.SeuSchema.
Dentro da pasta lib/lineup crie um arquivo chamado queue.ex com o seguinte conteúdo:
defmodule Lineup.Queue do
alias Lineup.Repo
alias Lineup.Queue.Ticket
@doc """
Creates a ticket.
## Examples
iex> create_ticket(%{field: value})
{:ok, %Ticket{}}
iex> create_ticket(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_ticket(attrs) do
%Ticket{}
|> Ticket.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking ticket changes.
## Examples
iex> change_ticket(ticket)
%Ecto.Changeset{data: %Ticket{}}
"""
def change_ticket(%Ticket{} = ticket, attrs \\ %{}) do
Ticket.changeset(ticket, attrs)
end
end
Tudo relacionado a tickets estará aqui. O Phoenix se inspira muito em Domain-Driven Design (DDD), onde cada parte da sua aplicação foca no seu domínio específico.
Usando alias para escrever um pouco menos de código, criamos uma função que recebe attrs e os valida com nosso Ticket.changeset/2, em seguida tentando inserir no nosso banco de dados. Esta função tem dois possíveis resultados: {:ok, %Ticket{...}} se tudo der certo ou {:error, %Ecto.Changeset{...}} se houver erros de validação. Também criamos uma função auxiliar para criar changesets dos nossos tickets.
Por que usar Queue.change_ticket/2 se alguém poderia facilmente usar Ticket.changeset/2?
change_ticket/2 em vez de quebrar a simplicidade da função Ticket.changeset/2.
#Testando nosso módulo diretamente do terminal
Podemos testar tudo que construímos até agora sem sequer começar a mexer na nossa LiveView! Como construímos um módulo Lineup.Queue que não depende de nada relacionado à web, podemos simplesmente iniciar um terminal interativo com o código mix do nosso projeto e executar a função create_ticket/2.
Usando seu terminal execute o comando indicado pelo prompt $:
$ iex -S mix
Erlang/OTP 28 [erts-16.0.1] [source] [64-bit] [smp:14:14] [ds:14:14:10] [async-threads:1] [jit]
Interactive Elixir (1.19.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
Usando iex -S mix entramos no modo Interactive Elixir (iex) contendo todas as funções do nosso projeto. No início da última linha você pode ver que iex(1)> virou seu novo prompt de comando. Vamos criar um alias para nosso contexto:
iex(1)> alias Lineup.Queue
Lineup.Queue
iex(2)>
Agora podemos escrever Queue. ao invés de Lineup.Queue.. Vamos criar nosso primeiro ticket! Execute: Queue.create_ticket(%{}).
iex(2)> Queue.create_ticket(%{})
{:error,
#Ecto.Changeset<
action: :insert,
changes: %{},
errors: [called_at: {"can't be blank", [validation: :required]}],
data: #Lineup.Queue.Ticket<>,
valid?: false,
...
>}
Ops! Quando definimos nosso schema dissemos que called_at era obrigatório! Mas na nossa aplicação real você precisa pegar a senha primeiro e só então será chamado. Sem sair do seu shell iex, altere em lib/lineup/queue/ticket.ex e salve o arquivo:
- |> validate_required([:called_at])
+ |> validate_required([])
Em seguida execute recompile dentro do iex e tente criar o ticket novamente. Dica: você pode pressionar a seta para cima para encontrar comandos anteriores no iex.
iex(3)> recompile
Compiling 1 file (.ex)
Generated lineup app
:ok
iex(4)> Queue.create_ticket(%{})
[debug] QUERY OK source="tickets" db=0.7ms decode=0.6ms idle=515.9ms
INSERT INTO "tickets" ("inserted_at","updated_at") VALUES (?1,?2) RETURNING "id" [~U[2026-04-29 19:23:38Z], ~U[2026-04-29 19:23:38Z]]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:365
{:ok,
%Lineup.Queue.Ticket{
__meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
id: 2,
called_at: nil,
inserted_at: ~U[2026-04-29 19:23:38Z],
updated_at: ~U[2026-04-29 19:23:38Z]
}}
Agora que sabemos que nosso Context funciona como esperado, podemos retornar ao trabalho na nossa LiveView!
#Usando nosso Context em nossa LiveView
Primeiramente, vamos adicionar uma nova rota ao nosso projeto:
scope "/", LineupWeb do
pipe_through :browser
live "/", TicketLive.Index, :index
live "/tickets/new", TicketLive.New, :new
end
Também precisamos escrever nossa nova LiveView TicketLive.New:
defmodule LineupWeb.TicketLive.New do
use LineupWeb, :live_view
alias Lineup.Queue
alias Lineup.Queue.Ticket
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage ticket records in your database.</:subtitle>
</.header>
<.form for={@form} id="ticket-form" phx-change="validate" phx-submit="save">
<.input field={@form[:called_at]} type="datetime-local" label="Called at" />
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Ticket</.button>
<.button navigate={~p"/"}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
ticket = %Ticket{}
{:ok,
socket
|> assign(:page_title, "New Ticket")
|> assign(:ticket, ticket)
|> assign(:form, to_form(Queue.change_ticket(ticket)))}
end
@impl true
def handle_event("validate", %{"ticket" => ticket_params}, socket) do
changeset = Queue.change_ticket(socket.assigns.ticket, ticket_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"ticket" => ticket_params}, socket) do
save_ticket(socket, ticket_params)
end
defp save_ticket(socket, ticket_params) do
case Queue.create_ticket(ticket_params) do
{:ok, _ticket} ->
{:noreply,
socket
|> put_flash(:info, "Ticket created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
end
Tem muito código aí! Para entender melhor o que acabamos de escrever, vamos desmontá-lo por partes.
#Nosso componente <.header>
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage ticket records in your database.</:subtitle>
</.header>
...
</Layouts.app>
"""
end
O <.header> é um componente do CoreComponents que pode ser usado para manter estilos de cabeçalho de página consistentes em toda a aplicação. Cansou de como eles parecem? Basta editar a definição do componente e ver todos os cabeçalhos serem atualizados. Um conselho geral de quem trabalhou muito com LiveView: ter componentes para encapsular estilos não só torna o código da função render/1 da sua LiveView mais elegante e legível como também facilita as atualizações. Se você se encontrar repetindo código por toda a sua UI, pode ser hora de nascer um novo componente. Como você pode ver, o <.header> também tem um slot opcional chamado <:subtitle>. Sinta-se à vontade para olhar a definição desse componente em lib/lineup_web/components/core_components.ex se quiser saber mais.
Você notou que usamos @page_title no nosso código HEEx?
@page_title seja um assign especial pois tem significado para a tag de título do HTML, isso não significa que você não pode usá-lo como qualquer outro assign.
#Nosso <.form>
~H"""
...
<.form for={@form} id="ticket-form" phx-change="validate" phx-submit="save">
<.input field={@form[:called_at]} type="datetime-local" label="Called at" />
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Ticket</.button>
<.button navigate={~p"/"}>Cancel</.button>
</footer>
</.form>
...
"""
Como aprendemos nas aulas do curso de Fundamentos, projetos Phoenix vêm com um componente <.form> built-in que deve ser usado para lidar com formulários em toda a sua base de código. Agora, desde o início, estamos usando os componentes <.input> e <.button> do CoreComponents, pois antes apenas os escrevemos para fins de aprendizado. Assim como o componente <.header>, se em algum momento você quiser mudar a aparência da sua aplicação, sempre pode editar esses estilos no CoreComponents.
Um novo atributo sobre o qual ainda não falamos é o phx-disable-with. Este atributo é um Phoenix binding que funciona exclusivamente para botões de envio de formulário, exibindo uma mensagem de carregamento enquanto o cliente aguarda a resposta do servidor. É recomendável usá-lo não só para criar uma melhor UX de carregamento com pouco código, mas também para evitar que alguém pressione o botão de envio várias vezes furiosamente, o que poderia levar a criações duplicadas.
#Nosso callback mount/3
@impl true
def mount(_params, _session, socket) do
ticket = %Ticket{}
changeset = Queue.change_ticket(ticket)
form = to_form(changeset)
{:ok,
socket
|> assign(:page_title, "New Ticket")
|> assign(:ticket, ticket)
|> assign(:form, form)}
end
Similar ao que aprendemos no curso de Fundamentos, usamos um changeset para rastrear a validação e então o convertemos para um Phoenix.Form usando a função to_form/2. Vamos ver na próxima seção por que também atribuímos o ticket vazio ao nosso socket.
#Nosso evento de validação
...
@impl true
def handle_event("validate", %{"ticket" => ticket_params}, socket) do
changeset = Queue.change_ticket(socket.assigns.ticket, ticket_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
...
Para aplicar validações precisamos criar um evento "validate" (já que escrevemos em HEEx que phx-change="validate") e receber os ticket_params. Para essa validação tudo que precisamos é acessar o %Ticket{} original (que está vazio) em socket.assigns.ticket (por isso atribuímos ele ao socket antes), usar Queue.change_ticket/2 novamente e então atribuir o novo valor do form junto com action: :validate para que nosso formulário saiba que deve exibir os erros de validação.
#Nosso evento de salvar
...
def handle_event("save", %{"ticket" => ticket_params}, socket) do
save_ticket(socket, ticket_params)
end
defp save_ticket(socket, ticket_params) do
case Queue.create_ticket(ticket_params) do
{:ok, _ticket} ->
{:noreply,
socket
|> put_flash(:info, "Ticket created successfully")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
De forma muito similar ao que acabamos de escrever no nosso evento "validate", agora vamos lidar com os mesmos ticket_params mas usando diretamente Queue.create_ticket/2 (lembre que ele também usa changesets por baixo dos panos, então qualquer erro de validação causará um {:error, %Ecto.Changeset{}}).
No nosso cenário de sucesso teremos {:ok, ticket} mas não precisamos do seu ID, então simplesmente ignoramos a variável usando underscore no nome e fazemos duas coisas:
-
Usamos
put_flash/3para adicionar uma mensagem informativa sobre a criação do ticket, que irá para o assign@flashque mencionamos antes. Pense no@flashcomo uma forma de passar notificações da LiveView para o seu frontend (embora ele possa fazer muito mais do que isso). -
Usamos
push_navigate/2para redirecionar nossos usuários para a página inicial. Vale mencionar quepush_navigate/2usa os mecanismos internos do LiveView para fazer redirecionamentos otimizados quando possível (entre views no mesmo escopo) usando mensagens WebSocket em vez de um refresh completo do navegador como redirecionamentos comuns fazem.
Se ocorrer um erro de validação recebemos o changeset e o convertemos em um form usando to_form/2.
Se você sentiu dificuldade de acompanhar o código nesta aula você pode ver o código pronto usando git checkout saving-data-done ou clonando em outra pasta com git clone https://github.com/adopt-liveview/lineup --branch saving-data-done.
#Resumindo!
- O Ecto usa o design pattern Repository para interagir com bancos de dados.
-
Sempre que precisarmos usar o banco de dados, vamos usar uma função do módulo
Repo. - O Ecto usa o design pattern schema migrations para modificar a estrutura do banco de dados.
-
Para criar uma migration você precisa rodar
mix ecto.gen.migration nome_da_migrationno terminal. -
Para aplicar as migrations pendentes você deve executar
mix ecto.migrateno terminal. -
Para desfazer as últimas migrations aplicadas você pode executar
mix ecto.rollbackno terminal. -
Um schema com
embedded_schema donão interage com o banco de dados, masschema "nome_da_tabela" doé tudo que você precisa para instruir o Ecto a interagir com aquela tabela. - Em projetos Phoenix concentramos as funções relacionadas a um domínio específico em um módulo de contexto, seguindo a inspiração do DDD.
-
No nosso projeto atual concentramos o domínio de gerenciamento de tickets no contexto
Lineup.Queue. -
Você pode usar
iex -S mixpara entrar no modo Interactive Elixir e testar todas as funções do seu projeto. - Quando seu contexto e schema estão bem modelados, adicionar funções à sua LiveView se torna trivial.
Feedback
Got any feedback about this page? Let us know!