Ecto and Contexts
Phoenix Ecto — schemas, changesets, queries, migrations, repositories, and context modules
Ecto is the database wrapper and query engine for Phoenix. Contexts provide the public API boundary for your domain logic.
Schemas
Schemas map database tables to Elixir structs:
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
field :slug, :string
field :views, :integer, default: 0
field :published_at, :utc_datetime
field :tags, {:array, :string}, default: []
belongs_to :author, MyApp.Accounts.User
has_many :comments, MyApp.Blog.Comment
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :slug, :author_id])
|> validate_required([:title, :body])
|> validate_length(:title, min: 3, max: 200)
|> unique_constraint(:slug)
|> assoc_constraint(:author)
end
endField types
| Type | Database | Elixir |
|---|---|---|
:string |
varchar/text | String.t |
:integer |
integer | integer |
:float |
float/double | float |
:boolean |
boolean | boolean |
:binary |
blob | binary |
:date |
date | Date.t |
:time |
time | Time.t |
:utc_datetime |
timestamp | DateTime.t |
:naive_datetime |
timestamp | NaiveDateTime.t |
:map / :map |
jsonb | map |
{:array, :string} |
varchar[] | [String.t] |
Changesets
Changesets cast, validate, and constrain data before it hits the database:
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :author_id])
|> validate_required([:title, :body])
|> validate_length(:title, min: 3, max: 200)
|> validate_format(:slug, ~r/^[a-z0-9-]+$/)
|> validate_number(:views, greater_than_or_equal_to: 0)
|> unique_constraint(:slug)
|> foreign_key_constraint(:author_id)
endValidation functions
| Function | Description |
|---|---|
validate_required(changeset, fields) |
Must be present |
validate_length(changeset, field, opts) |
min:, max:, is: |
validate_format(changeset, field, regex) |
Must match regex |
validate_number(changeset, field, opts) |
greater_than:, less_than:, equal_to:, etc. |
validate_inclusion(changeset, field, list) |
Must be in list |
validate_exclusion(changeset, field, list) |
Must not be in list |
validate_change(changeset, field, fun) |
Custom validation |
unique_constraint(changeset, field) |
DB-level uniqueness |
foreign_key_constraint(changeset, field) |
DB-level FK check |
assoc_constraint(changeset, assoc) |
Association must exist |
unsafe_validate_unique(changeset, field) |
Validate uniqueness without constraint |
Migrations
defmodule MyApp.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string, null: false
add :body, :text
add :slug, :string, null: false
add :views, :integer, default: 0
add :author_id, references(:users, on_delete: :delete_all)
timestamps()
end
create unique_index(:posts, [:slug])
create index(:posts, [:author_id])
end
endMigration commands
mix ecto.create # Create database
mix ecto.migrate # Run pending migrations
mix ecto.rollback # Rollback last migration
mix ecto.rollback --step 3 # Rollback last 3 migrations
mix ecto.drop # Drop database
mix ecto.reset # Drop + create + migrate + seed
mix ecto.setup # Create + migrate + seed
mix ecto.gen.migration add_email_to_users # Generate migrationMigration column options
| Option | Description |
|---|---|
null: false |
Not null constraint |
default: value |
Default value |
primary_key: true |
Primary key |
auto_increment: true |
Auto-increment |
size: n |
Field size |
on_delete: :delete_all |
Cascade deletes |
on_delete: :nilify_all |
Set FK to null on delete |
Queries
import Ecto.Query
# Basic queries
Post |> Repo.all()
Post |> Repo.get!(42)
Post |> Repo.get_by!(slug: "hello-world")
# Where clauses
from p in Post, where: p.published == true
Post |> where(published: true) |> Repo.all()
Post |> where([p], p.views > 100) |> Repo.all()
# Order, limit, offset
Post |> order_by(desc: :inserted_at) |> limit(10) |> Repo.all()
# Select specific fields
from p in Post, select: {p.title, p.views}
from p in Post, select: %{title: p.title, views: p.views}
# Joins
from p in Post,
join: c in assoc(p, :comments),
where: c.visible == true,
preload: [:comments]
Post |> join(:inner, [p], c in assoc(p, :comments))
|> preload([p, c], comments: c)
|> Repo.all()
# Aggregates
from p in Post, select: count(p.id)
from p in Post, select: avg(p.views)
from p in Post, group_by: p.author_id, select: {p.author_id, count(p.id)}
# Fragments (raw SQL)
from p in Post, where: fragment("lower(?)", p.title) == "hello"
from p in Post, where: fragment("? @@ plainto_tsquery(?)", p.search_vector, ^query)
# Like / ILike
from p in Post, where: ilike(p.title, ^"%elixir%")Repo operations
# Insert
Repo.insert(%Post{title: "Hello"})
Repo.insert!(%Post{title: "Hello"}) # raises on error
# Insert from changeset
case Repo.insert(Post.changeset(%Post{}, attrs)) do
{:ok, post} -> post
{:error, changeset} -> changeset
end
# Update
Repo.update(Post.changeset(post, %{title: "Updated"}))
# Delete
Repo.delete(post)
# Get
Repo.get(Post, 42)
Repo.get!(Post, 42)
Repo.get_by(Post, slug: "hello")
Repo.get_by!(Post, slug: "hello")
# All
Repo.all(Post)
# Aggregate
Repo.aggregate(Post, :count)
Repo.aggregate(Post, :avg, :views)
Repo.aggregate(Post, :count, :title) # COUNT(DISTINCT title)Contexts
Contexts group related functionality into modules with public APIs. They are the boundary between your web layer and business logic:
defmodule MyApp.Blog do
@moduledoc "The Blog context."
def list_posts do
Repo.all(from p in Post, order_by: [desc: :inserted_at])
end
def get_post!(id) do
Repo.get!(Post, id)
end
def get_post_by_slug!(slug) do
Repo.get_by!(Post, slug: slug)
end
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
def delete_post(%Post{} = post) do
Repo.delete(post)
end
def change_post(%Post{} = post, attrs \\ %{}) do
Post.changeset(post, attrs)
end
endConvention: Controllers and LiveViews should call context functions, never
Repodirectly. The context is the API boundary.
Repo configuration
# config/dev.exs
config :my_app, MyApp.Repo,
database: "my_app_dev",
hostname: "localhost",
username: "postgres",
password: "postgres",
pool_size: 10
# config/runtime.exs (for releases)
config :my_app, MyApp.Repo,
url: System.fetch_env!("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")