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
end

Field 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)
end

Validation 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
end

Migration 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 migration

Migration 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
end

Convention: Controllers and LiveViews should call context functions, never Repo directly. 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")