Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"},
{:ecto, "~> 3.9.5"}
])
Upon completing this lesson, a student should be able to answer the following questions.
- How do you validate complex structured data using Ecto?
- Why use an Ecto.Changeset instead of writing your own validation logic?
Ecto is a library we typically use to communicate with a database. We'll learn more about databases in future lessons, but for now, we'll see how Ecto helps to interact with data.
In addition to being the layer that we use to communicate with the database, Ecto is powerful for validating data. So far, you've created structs with their own (limited) data validation.
Structs are not always ideal for validating, as they only allow us to enforce keys. We then have to write functions with data validation or guards.
defmodule Person do
@enforce_keys [:name, :age]
defstruct @enforce_keys
def new(%{name: name, age: age}) when is_binary(name) and is_integer(age) do
{:ok,
%Person{
name: name,
age: age
}}
end
def new(_), do: {:error, :invalid_params}
end
Struct creation functions typically returns an {ok, struct}
tuple or an {:error, message}
tuple.
# Valid Params
{:ok, %Person{age: 20, name: "valid name"}} = Person.new(%{name: "valid name", age: 20})
# Invalid Params
{:error, :invalid_params} = Person.new(%{name: ["invalid name"], age: 20})
As the struct grows in complexity, this solution does not always scale well. That's because structs don't have any built-in data validation other than enforcing keys.
As an alternative, we can use Ecto Changesets to validate data.
An Ecto.Changeset struct allows us to validate data and return helpful error messages when something is invalid.
A changeset describes the expected shape of your data and ensures that data conforms to the desired shape.
flowchart
n[username]
User --> n --> s1[string]
n --> l[less than 40 characters]
n --> gr[greater than 3 characters]
User --> password --> s2[string]
password --> g[greater than 10 characters]
User --> a[accepted terms of service] --> true
To create a changeset, we need to know.
- the
initial_data
. - the expected data validation as
types
. - the attempted change from
params
. - the keys of
params
tocast/3
into data that conforms to the changeset.
flowchart
i[initial_data]
t[types]
p[params]
k[keys]
ca[cast/3]
c[Ecto.Changeset]
i --> ca
t --> ca
p --> ca
k --> ca
ca --> c
We use changesets to validate new data or validate a change to some already validated data.
Here we use the primitive types :string
and :integer
. For a full list of
the allowable types, see the Primitive Types table.
initial_data = %{}
params = %{name: "Peter", age: 22}
types = %{name: :string, age: :integer}
keys = [:name, :age]
changeset = Ecto.Changeset.cast({initial_data, types}, params, keys)
We've now bound the changeset
variable to an Ecto.Changeset
struct.
The changeset contains:
- changes: the
params
we want to update the source data. - data: the source data for the changeset.
- valid?: whether or not the desired change breaks any validation.
- errors: a keyword list of errors for any changes.
- action: We'll ignore this for now as it is more important when working with databases. See Changeset Actions.
flowchart
e[Ecto.Changeset]
a[action]
d[data]
v[valid?]
c[changes]
er[errors]
e --> a
e --> d
e --> v
e --> c
e --> er
The changeset will store the errors as a keyword list, and valid?
will be false when the parameters
are invalid.
invalid_params = %{name: 2, age: "hello"}
invalid_changeset = Ecto.Changeset.cast({initial_data, types}, invalid_params, keys)
You'll notice that the changeset stores errors
in a keyword list. Both the name
and age
are invalid, and each stores a different error message.
We can also add additional validations to the changeset. For a full list of functions, see the Ecto Validation Functions.
These validation functions change the errors
according to what they validate.
initial_data = %{}
# Notice We Are Missing Age.
params = %{name: "Peter"}
types = %{name: :string, age: :integer}
keys = [:name, :age]
changeset =
Ecto.Changeset.cast({initial_data, types}, params, keys)
|> Ecto.Changeset.validate_required(:age)
Let's take the User
example from above. A User
should have a username
between 3
and 40
characters,
a password greater than 10
characters, and should accept our :terms_and_conditions
.
Here's an example with invalid data. Notice there is a list of errors
on the returned changeset.
initial_data = %{}
# Intentionally Left Empty To Show Errors
params = %{}
types = %{username: :string, password: :string, terms_and_conditions: :boolean}
keys = [:username, :password, :terms_and_conditions]
invalid_changeset =
{initial_data, types}
|> Ecto.Changeset.cast(params, keys)
|> Ecto.Changeset.validate_required([:username, :password])
|> Ecto.Changeset.validate_length(:username, min: 3, max: 40)
|> Ecto.Changeset.validate_length(:password, min: 10)
|> Ecto.Changeset.validate_acceptance(:terms_and_conditions)
Here's an example with valid params. Notice there are no errors
on the changeset.
initial_data = %{}
params = %{username: "Peter", password: "secret_spider", terms_and_conditions: true}
types = %{username: :string, password: :string, terms_and_conditions: :boolean}
keys = [:username, :password, :terms_and_conditions]
valid_changeset =
Ecto.Changeset.cast({initial_data, types}, params, keys)
|> Ecto.Changeset.validate_required([:username, :password])
|> Ecto.Changeset.validate_length(:username, min: 3, max: 40)
|> Ecto.Changeset.validate_length(:password, min: 10)
|> Ecto.Changeset.validate_acceptance(:terms_and_conditions)
To apply changes, we can use Ecto.Changeset.apply_changes/1, which will only apply the changes if the changes are valid. Because we aren't using a database, we have to manually set the action to :update
.
Ecto.Changeset.apply_action(valid_changeset, :update)
If the changes are not valid, we will receive an error and the changeset.
Ecto.Changeset.apply_action(invalid_changeset, :update)
It's common to use this pattern to create validated data or handle the error.
case Ecto.Changeset.apply_action(valid_changeset, :update) do
{:ok, data} -> data
{:error, message} -> message
end
Use Ecto.Changeset
to validate some parameters to create a book map.
A book should have:
- A required
:title
string field between3
and100
characters. - A required
:content
string field.
Example Solution
initial_data = %{}
types = %{title: :string, content: :string}
params = %{title: "title", content: "content"}
keys = [:title, :content]
{initial_data, types}
|> Ecto.Changeset.cast(params, keys)
|> Ecto.Changeset.validate_required([:title, :content])
|> Ecto.Changeset.validate_length(:title, min: 3, max: 100)
|> Ecto.Changeset.apply_action(:update)
We often store the associated changeset and validations inside of a module that defines a struct. Conventionally, the module defines a changeset/2
function
to create the changeset and new/1
function to create the struct instance.
This module is called a Schemaless Changeset. The Schema is a concept we'll return to when we cover Databases.
For now, know that we can rely on the Schemaless Changeset to validate data before creating an instance of the struct.
Here's an example, taken by converting the User
struct above to use a schemaless changeset.
defmodule User do
# Define the struct for User with fields :username, :password, and :terms_and_conditions
defstruct [:username, :password, :terms_and_conditions]
# Define the types for the fields in the struct
@types %{username: :string, password: :string, terms_and_conditions: :boolean}
# Define the changeset function for a User struct
def changeset(%User{} = user, params) do
# Cast the user struct and the types defined earlier with the keys from the params map
{user, @types}
|> Ecto.Changeset.cast(params, Map.keys(@types))
# Validate that the username and password fields are present
|> Ecto.Changeset.validate_required([:username, :password])
# Validate that the password field is at least 10 characters long
|> Ecto.Changeset.validate_length(:password, min: 10)
# Validate that the username field is between 3 and 40 characters long
|> Ecto.Changeset.validate_length(:username, min: 3, max: 40)
# Validate that the terms_and_conditions field is accepted
|> Ecto.Changeset.validate_acceptance(:terms_and_conditions)
end
# Define a new function for creating a new user
def new(params) do
# Start with an empty User struct
%User{}
# Pass the struct and the params to the changeset function
|> changeset(params)
# Apply the update action to the changeset
|> Ecto.Changeset.apply_action(:update)
end
end
# Create A New User With The Provided Parameters
User.new(%{username: "Peter Parker", password: "secret_spider", terms_and_conditions: true})
Create a Book
schemaless changeset struct. A book should have:
- A required
:title
string field between3
and100
characters. - A required
:content
string field. - An
:author
string field. - An
:is_licenced
field which must always be true. - An
:is_published
boolean field.
Example Solution
defmodule Book do
defstruct [:title, :content]
@types %{
title: :string,
content: :string,
author: :string,
is_licenced: :boolean,
is_published: :boolean
}
def changeset(%Book{} = book, params) do
{book, @types}
|> Ecto.Changeset.cast(params, Map.keys(@types))
|> Ecto.Changeset.validate_required([:title, :content])
|> Ecto.Changeset.validate_length(:title, min: 3, max: 100)
|> Ecto.Changeset.validate_acceptance(:is_licenced)
end
def new(params) do
%Book{}
|> changeset(params)
|> Ecto.Changeset.apply_action(:update)
end
end
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Ecto Changesets reading"
$ git push
We're proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.