O projeto Rockelivery faz parte do Bootcamp da Rocketseat, ministrado pelo professor Rafael Camarda.
Eu usei também alguns trechos da documentação oficial do Ecto e algumas explicações do site Elixir School. Existe um guia bem bacana sobre Ecto e em português que você pode estudar também: https://elixirschool.com/pt/lessons/ecto/basics/
Para você poder acompanhar a construção deste projeto é preciso ter o conhecimento básico da Linguagem Elixir :)
Conteúdo:
- 📚 O que vamos aprender?
- ❔ Como vai ser o projeto?
- 🔧 Setup inicial do projeto
- 👦 Criando a Migração e a Tabela do Usuário
- 🧑🏻 Criando o Schema do User
- ✔️ Usando Changesets
📚 O que vamos aprender?
- Instalar dependências
- Criar rotas (CRUD completo)
- create
- read
- update
- delete
- Criar Plugs
- Entender o Ecto
- interagir com bancos de dados
- migration
- schema
- changeset
- Testar uma aplicação Phoenix
❔ Como vai ser o projeto?
- Usuário pode se cadastrar e gerenciar a conta dele
- Items do restaurante podem ser cadastrados
- Usuário pode realizar pedidos
- Para realizar pedidos, o usuário tem que se logar
- O CEP do usuário deve ser válido, então vamos usar uma API para validar o CEP e aprender como realizar chamadas HTTP
- Semanalmente, um relatório de pedidos por usuário deve ser gerado, para isso vamos usar
GenServers
.
As tabelas do banco de dados serão essas:
🔧 Setup inicial do projeto
Se você ainda não tem o Elixir, Phoenix e o PostgreSQL instalados, você pode acessar outro artigo meu onde explico sobre a instalação deles: Instalação das ferramentas de uma pessoa desenvolvedora Elixir
Observação antes de criar o projeto:
Se você está usando uma versão do Phoenix anterior a versão 1.6 use --no-webpack
ao invés de --no-assets
Comando para criar o projeto (versão 1.6 e posterior):
$ mix phx.new rockelivery --no-html --no-assets
Comando para criar o projeto (versões anteriores):
$ mix phx.new rockelivery --no-html --no-webpack
Após esse comando, ele nos perguntará se queremos instalar as dependências. Vamos dizer que sim digitando y
e apertando a tecla ENTER
.
Fetch and install dependencies? [Yn] y
* running mix deps.get
* running mix deps.compile
...
Por que temos que usar as opções --no-webpack ou --no-assets
e --no-html
?
- Porque não vamos fazer front-end (HTML, CSS, JavaScript);
- E, porque vamos construir apenas uma API JSON.
Se fossemos usar só para front-end usaríamos a opção --no-ecto
, assim as configurações e arquivos para banco de dados não seriam gerados. Você pode encontrar outras opções aqui: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html
Entre na pasta do projeto com o comando:
$ cd rockelivery
👦 Criando a Migração e a Tabela do Usuário
Para criar e modificar tabelas no banco de dados, utilizamos as migrações do Ecto. Cada migração descreve uma série de ações para serem realizadas no nosso banco, como quais tabelas criar ou atualizar.
Você pode estar se perguntando agora, mas o que é esse tal de Ecto? Pois bem, vou te responder agora, o Ecto é um projeto oficial do Elixir que fornece uma camada de banco de dados e linguagem integrada para consultas. Com o Ecto podemos criar migrações, definir esquemas, inserir e atualizar registros, e fazer consultas no banco de dados.
A convenção no Ecto é pluralizar o nome das tabelas, portanto, vamos nomear a nossa tabela como users
. Para criar a migração vamos rodar o comando abaixo:
$ mix ecto.gen.migration create_users_table
Esse comando irá gerar um novo arquivo na pasta priv/repo/migrations
contendo uma timestamp
no nome. Se navegarmos para esse diretório e abrirmos a migração, veremos algo assim:
Em priv/repo/migrations/"número-da-timestamp"_create_users_table.exs
:
defmodule Rockelivery.Repo.Migrations.CreateUsersTable do
use Ecto.Migration
def change do
end
end
A timestamp
é importante para o Ecto saber em qual ordem ele precisa criar as tabelas.
Vamos criar a nossa tabela dentro da função change
. Estar dentro da função change
é importante para o Ecto criar a tabela quando rodarmos o comando de create
ou desfazer automaticamente o que criamos caso rodemos o comando de rollback
.
Existem também as funções up/0
e down/0
onde especificamos com detalhes o que queremos criar no up/0
e o que queremos desfazer no down/0
. Mas ter que escrever as funções up/0
e down/0
para cada migração é entediante e sujeito a erros. Por curiosidade, vou deixar o link da documentação explicando mais sobre, mas por aqui vamos deixar tudo dentro do change
mesmo.
up e down
: https://hexdocs.pm/ecto_sql/Ecto.Migration.html
Vamos criar a nossa tabela User com os campos abaixo:
- ID
- address
- age
- cep
- CPF
- name
- password
Em priv/repo/migrations/número-da-timestamp_create_users_table.exs
:
defmodule Rockelivery.Repo.Migrations.CreateUsersTable do
use Ecto.Migration
def change do
# A convenção no `Ecto` é pluralizar o nome das tabelas,
# portanto, vamos nomear a nossa tabela como :users.
create table :users do
# o id é gerado automaticamente então não precisamos criar
add :address, :string
add :age, :integer
add :cep, :string
add :cpf, :string
add :email, :string
add :name, :string
add :password_hash, :string
# Precisamos do password nomeado como password_hash
# pois vamos criptografar a senha do usuário...
# essa função de timestamps gera automaticamente os campos
# inserted_at para guardar a hora de criação do registro
# e updated_at para guardar a hora da atualização do registro
timestamps()
end
# O índice unique não deixará o cpf do usuário se repetir
create unique_index(:users, [:cpf])
# E também não deixará o email do usuário se repetir,
create unique_index(:users, [:email])
# ou seja, esses dois campos serão únicos
# Por curiosidade, existe também esse formato:
# create index("users", [:email], unique: true)
end
end
Alterando ID inteiro para UUID
Em aplicações Phoenix o ID é gerado automaticamente por padrão como inteiro, mas podemos modificar para que seja gerado automaticamente no formato UUID4.
O que é um UUID?
Um identificador único universal (do inglês universally unique identifier - UUID) é um número de 128 bits usado para identificar informações em sistemas de computação e dificilmente um UUID se repete.
Exemplo de UUID4: 297b4329-2982-4e6d-b2b1-6cf4b0c7a351
Gerador de UUID: https://www.uuidgenerator.net/
Vamos colocar as configurações no arquivo config/config.exs
# abaixo desse código:
config :rockelivery,
ecto_repos: [Rockelivery.Repo]
# vamos colocar esse código:
config :rockelivery, Rockelivery.Repo,
migration_primary_key: [type: :binary_id],
migration_foreign_key: [type: :binary_id]
-
:rockelivery
é o nome da nossa app -
Rockelivery.Repo
é o modulo que conversa com o banco de dados -
migration_primary_key
é a chave primaria -
migration_foreign_key
é a chave estrangeira -
:binary_id
é um UUID na versão 4
DICA: Podemos usar a opção --binary-id
junto do comando que criamos o projeto. Desta forma, ele irá gerar automaticamente o código para configurar os IDs como UUID.
Agora vamos criar a nossa tabela no banco de dados rodando o comando:
$ mix ecto.migrate
Após, você deverá ver algo semelhante no terminal:
18:35:08.572 [info] == Running 20210525185314 Rockelivery.Repo.Migrations.CreateUsersTable.change/0 forward
18:35:08.575 [info] create table users
18:35:08.583 [info] create index users_cpf_index
18:35:08.585 [info] create index users_email_index
18:35:08.589 [info] == Migrated 20210525185314 in 0.0s
Podemos verificar no pgAdmim
que a tabela foi criada com sucesso:
🧑🏻 Criando o Schema do User
Um esquema é um módulo que define um mapeando dos campos de uma tabela.
Enquanto nas tabelas utilizamos o plural, no esquema tipicamente se utiliza o singular. Então, criaremos um esquema User
para a nossa tabela.
No diretório das regras de negócio lib/rockelivery
, vamos criar o arquivo user.ex
, e o código ficará assim:
defmodule Rockelivery.User do
use Ecto.Schema
# Como alteramos o tipo da chave primária,
# precisamos configurar ela aqui,
# usando o atributo de esquema @primary_key
# podemos informar que temos um campo :id,
# do tipo UUID (:binary_id)
# e que deve ser gerado automaticamente.
@primary_key {:id, :binary_id, autogenerate: true}
# "users" no plural é nome da tabela
schema "users" do
field :address, :string
field :age, :integer
field :cep, :string
field :cpf, :string
field :email, :string
field :name, :string
field :password_hash, :string
timestamps()
end
end
@primary_key
- configura a chave primária do esquema. Ele espera uma tupla {field_name, type, options}
com o nome do campo da chave primária, tipo (normalmente :id ou :binary_id, mas pode ser qualquer tipo) e opções. Leia mais sobre atributos de esquemas
: https://hexdocs.pm/ecto/Ecto.Schema.html#module-schema-attributes
Agora que já temos o nosso esquema configurado, podemos perceber que um esquema (schema)
nada mais é do que uma struct com metadados
e, de maneira similar, podemos atualizar nossos esquemas como poderíamos fazer com qualquer outro map
ou struct
em Elixir:
Executando iex -S mix
no terminal podemos fazer os testes:
iex> user = %Rockelivery.User{}
%Rockelivery.User{
__meta__: #Ecto.Schema.Metadata<:built, "users">,
address: nil,
age: nil,
cep: nil,
cpf: nil,
email: nil,
id: nil,
inserted_at: nil,
name: nil,
password_hash: nil,
updated_at: nil
}
iex> user = %{user | age: 28}
%Rockelivery.User{
__meta__: #Ecto.Schema.Metadata<:built, "users">,
address: nil,
age: 28,
cep: nil,
cpf: nil,
email: nil,
id: nil,
inserted_at: nil,
name: nil,
password_hash: nil,
updated_at: nil
}
iex> user.age
28
DICA: Podemos criar o arquivo de migração juntamente com o do esquema executando o gerador do Phoenix:
$ mix phx.gen.schema User users address age:integer cep CPF email name password_hash
Você pode ver mais detalhes na documentação: https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html#content
✔️ Usando Changesets
Existe um maneira melhor para alterarmos os dados em um schema. Usando um Changeset (Conjunto de Mudanças) conseguimos alterar os dados fazendo validações. Você pode obter mais detalhes visitando a documentação: https://hexdocs.pm/ecto/Ecto.Changeset.html#module-the-ecto-changeset-struct
Em lib/rockelivery/user.ex
:
defmodule Rockelivery.User do
use Ecto.Schema
import Ecto.Changeset
# variáveis de módulo (Module Attributes) começam com @
# essas variáveis só podem ser usadas dentro do módulo
# essa variável guarda os campos que são permitidos
# para serem alterados
@fields_that_can_be_changed [
:address,
:age,
:cep,
:cpf,
:email,
:name,
:password_hash
]
# essa variável guarda os campos obrigatórios
# que devem ser preenchidos
@required_fields [
:address,
:age,
:cep,
:cpf,
:email,
:name,
:password_hash
]
@primary_key {:id, :binary_id, autogenerate: true}
schema "users" do
field :address, :string
field :age, :integer
field :cep, :string
field :cpf, :string
field :email, :string
field :name, :string
field :password_hash, :string
timestamps()
end
# Nesta função changeset vamos criar um changeset com a função cast
# e validar os campos obrigatórios com validate_required
def changeset(%{} = params) do
# %__MODULE__{} é igual a %Rockelivery.User{} que é o nosso Schema
%__MODULE__{}
# Ecto.Changeset.cast(Schema, %{} = params, [] = lista_de_campos_permitidos_a_serem_alterados)
# O cast retorna o changeset
|> cast(params, @fields_that_can_be_changed)
# Ecto.Changeset.validate_required(changeset, [] = campos_que_devem_ser_preenchidos)
|> validate_required(@required_fields)
end
end
Enquanto a função Ecto.Changeset.cast
é usada para trabalhar com dados externos, existe a função Ecto.Changeset.change
usada para trabalhar com dados internos da aplicação.
Elas são semelhantes, mas a função Ecto.Changeset.change
é útil para alterar diretamente uma struct
sem realizar castings (conversão de tipos) ou validações. Ao contrário da função cast
, na change
não temos a possibilidade de não permitir que certo dado seja alterado.
Entendendo mais sobre casting (conversão de tipos):
Nós definimos que o campo age
é do tipo inteiro
mas ao usar a função change
no lugar do cast
isso é ignorado:
Usando a função cast
, ela só aceitará um tipo inteiro:
Se colocarmos um número como string ela fará a conversão (casting) para inteiro automaticamente:
Veja mais sobre a função change: https://hexdocs.pm/ecto/Ecto.Changeset.html#change/2
Veja mais sobre a função cast:
https://hexdocs.pm/ecto/Ecto.Changeset.html#cast/4
Changeset Válido
Agora vamos preencher todos os campos para deixar o changeset totalmente válido:
Validações e Restrições
Os changesets do Ecto fornecem validações e restrições (constraints) que acabam se transformando em erros caso algo dê errado.
A diferença entre elas é que a maioria das validações pode ser executada sem a necessidade de interagir com o banco de dados e, portanto, são sempre executados antes de tentar inserir ou atualizar a entrada no banco de dados. Algumas validações podem acontecer no banco de dados, mas são inerentemente inseguras. Essas validações começam com um prefixo unsafe_
, como unsafe_validate_unique/3
.
Por outro lado, as constraints dependem do banco de dados e estão sempre seguras. Como consequência, as validações são sempre verificadas antes das constraints. As constraints nem mesmo serão verificadas em caso de falha nas validações.
Aqui você pode encontrar várias funções para validação:
https://hexdocs.pm/ecto/Ecto.Changeset.html#module-external-vs-internal-data
Vamos adicionar novas validações para o nosso changeset.
Em lib/rockelivery/user.ex
:
def changeset(%{} = params) do
%__MODULE__{}
|> cast(params, @fields_that_can_be_changed)
|> validate_required(@required_fields)
|> validate_length(:password_hash, min: 6)
|> validate_length(:cep, is: 8)
|> validate_length(:cpf, is: 11)
# idade deve ser maior ou igual a 18
|> validate_number(:age, greater_than_or_equal_to: 18)
# email deve conter um caractere @
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
|> unique_constraint(:cpf)
end
Executando iex -S mix
no terminal, podemos verificar as validações:
iex> changeset = Rockelivery.User.changeset(%{age: "1", address: "rua..", cep: "989", cpf: "212", email: "email", name: "teste", password_hash: "123"})
#Ecto.Changeset<
action: nil,
changes: %{
address: "rua..",
age: 1,
cep: "989",
cpf: "212",
email: "email",
name: "teste",
password_hash: "123"
},
errors: [
email: {"has invalid format", [validation: :format]},
age: {"must be greater than or equal to %{number}",
[validation: :number, kind: :greater_than_or_equal_to, number: 18]},
cpf: {"should be %{count} character(s)",
[count: 11, validation: :length, kind: :is, type: :string]},
cep: {"should be %{count} character(s)",
[count: 8, validation: :length, kind: :is, type: :string]},
password_hash: {"should be at least %{count} character(s)",
[count: 6, validation: :length, kind: :min, type: :string]}
],
data: #Rockelivery.User<>,
valid?: false
>
Encriptando a Senha
Primeiramente precisaremos ter a bibliteca externa Argon2. Você pode obter informações de como instalar ela aqui: Instalação das ferramentas de uma pessoa desenvolvedora Elixir
Em lib/rockelivery/user.ex
:
defmodule Rockelivery.User do
use Ecto.Schema
import Ecto.Changeset
@fields_that_can_be_changed [
:address,
:age,
:cep,
:cpf,
:email,
:name,
# altere password_hash para password.
# o campo password_hash pode ser retirado
# dos campos permitidos pois será utilizada
# a função change para alterar o password_hash
:password
]
@required_fields [
:address,
:age,
:cep,
:cpf,
:email,
:name,
# altere password_hash para password.
# o password_hash só será alterado após as validações,
# por isso deve ser retirado dos campos obrigatórios.
:password
]
@primary_key {:id, :binary_id, autogenerate: true}
schema "users" do
field :address, :string
field :age, :integer
field :cep, :string
field :cpf, :string
field :email, :string
field :name, :string
# adicionando um campo virtual
# o valor dele não será gravado no banco de dados
# será apenas para capturar a senha do usuário
field :password, :string, virtual: true
field :password_hash, :string
timestamps()
end
def changeset(%{} = params) do
%__MODULE__{}
|> cast(params, @fields_that_can_be_changed)
|> validate_required(@required_fields)
|> validate_length(:password_hash, min: 6)
|> validate_length(:cep, is: 8)
|> validate_length(:cpf, is: 11)
|> validate_number(:age, greater_than_or_equal_to: 18)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email)
|> unique_constraint(:cpf)
# adicione a função para encriptar a senha
|> put_pass_hash()
end
defp put_pass_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
change(changeset, Argon2.add_hash(password))
end
# caso o changeset seja 'valid?: false'
defp put_pass_hash(changeset), do: changeset
end
Executando iex -S mix
no terminal, podemos verificar que a função Argon2.add_hash
preencheu o campo password_hash
encriptando o valor de password
:
iex> changeset = Rockelivery.User.changeset(%{age: 28, address: "rua..", cep: "12345678", cpf: "12345678910", email: "maiqui@email.com", name: "Maiqui", password: "123456"})
#Ecto.Changeset<
action: nil,
changes: %{
address: "rua..",
age: 28,
cep: "12345678",
cpf: "12345678910",
email: "maiqui@email.com",
name: "Maiqui",
password: "123456",
password_hash: "$argon2id$v=19$m=131072,t=8,p=4$ifRuVbsF29358ZZTbdYxGg$iVGPxDdiG5bDVW1yVjfibaiwNSqKHsBOAmvzzfrYd7A"
},
errors: [],
data: #Rockelivery.User<>,
valid?: true
>
Agora você já pode partir para a parte 2 do projeto :)
Top comments (2)
Muito bom. Parabéns!
Valeeeu Thiago :)