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")