917 lines
27 KiB
Markdown
917 lines
27 KiB
Markdown
# Elixir Part 3: Phoenix Framework — v1.8.5 (Current)
|
|
|
|
Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.
|
|
|
|
## What's New in Phoenix 1.8
|
|
|
|
- **Scopes** in generators — secure data access by default (e.g., `current_user` automatically applied)
|
|
- **Magic links** (passwordless auth) and **"sudo mode"** in `mix phx.gen.auth`
|
|
- **daisyUI** integration — light/dark/system mode support out of the box
|
|
- **Simplified layouts** — single `root.html.heex` wraps everything; dynamic layouts are function components
|
|
- **`use Phoenix.Controller` now requires `:formats`** — must specify `formats: [:html]` or `formats: [:html, :json]`
|
|
- **Updated security headers** — `content-security-policy` with `base-uri 'self'; frame-ancestors 'self'`; dropped deprecated `x-frame-options` and `x-download-options`
|
|
- **`config` variable removed** from `Phoenix.Endpoint` — use `Application.compile_env/3` instead
|
|
- **Deprecated**: `:namespace`, `:put_default_views`, layouts without modules, `:trailing_slash` in router
|
|
|
|
---
|
|
|
|
## Project Setup
|
|
|
|
```bash
|
|
# New project (Phoenix Express — quickest path)
|
|
curl https://new.phoenixframework.org/my_app | sh
|
|
|
|
# Traditional setup
|
|
mix phx.new my_app
|
|
mix phx.new my_app --no-ecto # Without database
|
|
mix phx.new my_app --no-html # API only
|
|
mix phx.new my_app --database sqlite3 # SQLite instead of Postgres
|
|
```
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
my_app/
|
|
├── lib/
|
|
│ ├── my_app/ # Business logic (contexts, schemas)
|
|
│ │ ├── application.ex # Supervision tree
|
|
│ │ ├── repo.ex # Ecto Repo
|
|
│ │ └── catalog/ # Context: Catalog
|
|
│ │ ├── product.ex # Schema
|
|
│ │ └── catalog.ex # Context module
|
|
│ └── my_app_web/ # Web layer
|
|
│ ├── endpoint.ex # HTTP entry point
|
|
│ ├── router.ex # Routes + pipelines
|
|
│ ├── components/
|
|
│ │ ├── core_components.ex # Shared UI components
|
|
│ │ └── layouts.ex # Layout components
|
|
│ ├── controllers/
|
|
│ │ ├── page_controller.ex
|
|
│ │ └── page_html/ # Templates for controller
|
|
│ │ └── home.html.heex
|
|
│ └── live/ # LiveView modules
|
|
│ └── counter_live.ex
|
|
├── config/
|
|
│ ├── config.exs # Compile-time config
|
|
│ ├── dev.exs / prod.exs
|
|
│ └── runtime.exs # Runtime config (env vars, secrets)
|
|
├── priv/
|
|
│ ├── repo/migrations/
|
|
│ └── static/ # Static assets
|
|
├── test/
|
|
│ ├── support/
|
|
│ │ ├── conn_case.ex # Test helpers for controllers
|
|
│ │ └── data_case.ex # Test helpers for data layer
|
|
│ └── my_app_web/
|
|
└── mix.exs
|
|
```
|
|
|
|
---
|
|
|
|
## Router & Pipelines
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.Router do
|
|
use MyAppWeb, :router
|
|
|
|
pipeline :browser do
|
|
plug :accepts, ["html"]
|
|
plug :fetch_session
|
|
plug :fetch_live_flash
|
|
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
|
|
plug :protect_from_forgery
|
|
plug :put_secure_browser_headers
|
|
end
|
|
|
|
pipeline :api do
|
|
plug :accepts, ["json"]
|
|
end
|
|
|
|
scope "/", MyAppWeb do
|
|
pipe_through :browser
|
|
|
|
get "/", PageController, :home
|
|
live "/counter", CounterLive # LiveView route
|
|
resources "/products", ProductController # RESTful CRUD
|
|
end
|
|
|
|
scope "/api", MyAppWeb do
|
|
pipe_through :api
|
|
|
|
resources "/items", ItemController, except: [:new, :edit]
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Verified Routes — `~p` Sigil
|
|
|
|
**Always use `~p` instead of string paths.** Compile-time verified against your router.
|
|
|
|
```elixir
|
|
# In templates (HEEx)
|
|
~H"""
|
|
<a href={~p"/products/#{product}"}>View</a>
|
|
<a href={~p"/products/#{product}/edit"}>Edit</a>
|
|
"""
|
|
|
|
# In controllers
|
|
redirect(conn, to: ~p"/products/#{product}")
|
|
|
|
# With query params
|
|
~p"/products?page=#{page}&sort=name"
|
|
~p"/products?#{%{page: 1, sort: "name"}}"
|
|
|
|
# URL generation (includes host)
|
|
url(~p"/products/#{product}")
|
|
# => "https://example.com/products/42"
|
|
```
|
|
|
|
---
|
|
|
|
## Controllers (1.8 Style)
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.ProductController do
|
|
use MyAppWeb, :controller
|
|
|
|
# REQUIRED in 1.8: specify formats
|
|
# This is typically set in MyAppWeb :controller function
|
|
|
|
def index(conn, _params) do
|
|
products = Catalog.list_products()
|
|
render(conn, :index, products: products)
|
|
end
|
|
|
|
def show(conn, %{"id" => id}) do
|
|
product = Catalog.get_product!(id)
|
|
render(conn, :show, product: product)
|
|
end
|
|
|
|
def create(conn, %{"product" => product_params}) do
|
|
case Catalog.create_product(product_params) do
|
|
{:ok, product} ->
|
|
conn
|
|
|> put_flash(:info, "Product created.")
|
|
|> redirect(to: ~p"/products/#{product}")
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
render(conn, :new, changeset: changeset)
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**View module naming:** For `ProductController`, Phoenix looks for `ProductHTML` (for `:html` format) and `ProductJSON` (for `:json` format).
|
|
|
|
```elixir
|
|
# lib/my_app_web/controllers/product_html.ex
|
|
defmodule MyAppWeb.ProductHTML do
|
|
use MyAppWeb, :html
|
|
embed_templates "product_html/*" # Loads .heex files from directory
|
|
end
|
|
|
|
# lib/my_app_web/controllers/product_json.ex
|
|
defmodule MyAppWeb.ProductJSON do
|
|
def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
|
|
def show(%{product: product}), do: %{data: data(product)}
|
|
defp data(product), do: %{id: product.id, title: product.title, price: product.price}
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Components and HEEx
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.CoreComponents do
|
|
use Phoenix.Component
|
|
|
|
# Declare attributes with types and docs
|
|
attr :type, :string, default: "button"
|
|
attr :class, :string, default: nil
|
|
attr :rest, :global # Passes through all other HTML attrs
|
|
slot :inner_block, required: true
|
|
|
|
def button(assigns) do
|
|
~H"""
|
|
<button type={@type} class={["btn", @class]} {@rest}>
|
|
{render_slot(@inner_block)}
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
# Table component with slots
|
|
attr :rows, :list, required: true
|
|
slot :col, required: true do
|
|
attr :label, :string
|
|
end
|
|
|
|
def table(assigns) do
|
|
~H"""
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th :for={col <- @col}>{col[:label]}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr :for={row <- @rows}>
|
|
<td :for={col <- @col}>{render_slot(col, row)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
"""
|
|
end
|
|
end
|
|
```
|
|
|
|
**HEEx syntax notes:**
|
|
- `{@var}` — render assign (curly braces, not `<%= %>`)
|
|
- `:if={condition}` — conditional rendering on any tag
|
|
- `:for={item <- list}` — iteration on any tag
|
|
- `<.component_name />` — call function component with dot notation
|
|
- `<Component.name />` — call remote component with module name
|
|
- `{render_slot(@inner_block)}` — render slot content
|
|
- `<:slot_name>content</:slot_name>` — named slot content
|
|
|
|
---
|
|
|
|
## LiveView
|
|
|
|
```elixir
|
|
defmodule MyAppWeb.SearchLive do
|
|
use MyAppWeb, :live_view
|
|
|
|
def mount(_params, _session, socket) do
|
|
{:ok, assign(socket, query: "", results: [])}
|
|
end
|
|
|
|
def handle_params(%{"q" => query}, _uri, socket) do
|
|
{:noreply, assign(socket, query: query, results: search(query))}
|
|
end
|
|
|
|
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
|
|
|
def handle_event("search", %{"query" => query}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(query: query, results: search(query))
|
|
|> push_patch(to: ~p"/search?q=#{query}")}
|
|
end
|
|
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<form phx-submit="search">
|
|
<input name="query" value={@query} phx-debounce="300" />
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
|
|
<div :for={result <- @results} class="result">
|
|
<h3>{result.title}</h3>
|
|
<p>{result.summary}</p>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
defp search(query), do: MyApp.Search.find(query)
|
|
end
|
|
```
|
|
|
|
**LiveView lifecycle:** `mount/3` → `handle_params/3` → `render/1`. Events via `handle_event/3`. Server pushes via `handle_info/2`.
|
|
|
|
**Key patterns:**
|
|
- `assign/2,3` — set socket assigns
|
|
- `push_navigate/2` — navigate to new LiveView (full mount)
|
|
- `push_patch/2` — update URL without full remount (calls `handle_params`)
|
|
- `push_event/3` — push event to client JS hooks
|
|
- `stream/3,4` — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
|
|
- `async_assign/3` + `assign_async/3` — async data loading with loading/error states
|
|
|
|
---
|
|
|
|
## Channels — Real-Time WebSocket
|
|
|
|
```elixir
|
|
# In endpoint.ex
|
|
socket "/socket", MyAppWeb.UserSocket,
|
|
websocket: true,
|
|
longpoll: false
|
|
|
|
# lib/my_app_web/channels/user_socket.ex
|
|
defmodule MyAppWeb.UserSocket do
|
|
use Phoenix.Socket
|
|
|
|
channel "room:*", MyAppWeb.RoomChannel
|
|
|
|
def connect(%{"token" => token}, socket, _connect_info) do
|
|
case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
|
|
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
|
|
{:error, _} -> :error
|
|
end
|
|
end
|
|
|
|
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
|
|
end
|
|
|
|
# lib/my_app_web/channels/room_channel.ex
|
|
defmodule MyAppWeb.RoomChannel do
|
|
use MyAppWeb, :channel
|
|
|
|
def join("room:" <> room_id, _params, socket) do
|
|
{:ok, assign(socket, :room_id, room_id)}
|
|
end
|
|
|
|
def handle_in("new_msg", %{"body" => body}, socket) do
|
|
broadcast!(socket, "new_msg", %{
|
|
body: body,
|
|
user_id: socket.assigns.user_id
|
|
})
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
```
|
|
|
|
**Force disconnect all sessions for a user:**
|
|
```elixir
|
|
MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
|
|
```
|
|
|
|
---
|
|
|
|
## Contexts — Business Logic Boundary
|
|
|
|
Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.
|
|
|
|
```elixir
|
|
# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
|
|
defmodule MyApp.Catalog do
|
|
import Ecto.Query
|
|
alias MyApp.Repo
|
|
alias MyApp.Catalog.Product
|
|
|
|
def list_products do
|
|
Repo.all(Product)
|
|
end
|
|
|
|
def get_product!(id), do: Repo.get!(Product, id)
|
|
|
|
def create_product(attrs \\ %{}) do
|
|
%Product{}
|
|
|> Product.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
def update_product(%Product{} = product, attrs) do
|
|
product
|
|
|> Product.changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
def delete_product(%Product{} = product) do
|
|
Repo.delete(product)
|
|
end
|
|
|
|
def change_product(%Product{} = product, attrs \\ %{}) do
|
|
Product.changeset(product, attrs)
|
|
end
|
|
end
|
|
```
|
|
|
|
**Context design principles:**
|
|
- One context per bounded domain (Catalog, Accounts, Orders)
|
|
- Contexts own their schemas — other contexts reference by ID, not struct
|
|
- Cross-context calls go through the public context API, never access another context's Repo directly
|
|
- Contexts can nest related schemas (Comments under Posts)
|
|
|
|
---
|
|
|
|
## Authentication — `mix phx.gen.auth`
|
|
|
|
```bash
|
|
mix phx.gen.auth Accounts User users
|
|
```
|
|
|
|
Phoenix 1.8 generates:
|
|
- **Magic links** (passwordless) — email-based login links
|
|
- **"Sudo mode"** — re-authentication for sensitive actions
|
|
- Session-based auth with secure token handling
|
|
- Email confirmation and password reset flows
|
|
- `require_authenticated_user` plug for protected routes
|
|
|
|
## Scopes (New in 1.8)
|
|
|
|
Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.
|
|
|
|
```bash
|
|
mix phx.gen.live Posts Post posts title body:text --scope current_user
|
|
```
|
|
|
|
This generates code that automatically passes `current_user` to context functions, ensuring users only see their own data.
|
|
|
|
---
|
|
|
|
## Ecto — Database Layer
|
|
|
|
Ecto has four core components: **Repo** (database wrapper), **Schema** (data mapping), **Changeset** (validation + change tracking), and **Query** (composable queries). Always preload associations explicitly — Ecto never lazy-loads.
|
|
|
|
### Schema
|
|
|
|
```elixir
|
|
defmodule MyApp.Catalog.Product do
|
|
use Ecto.Schema
|
|
import Ecto.Changeset
|
|
|
|
schema "products" do
|
|
field :title, :string
|
|
field :price, :decimal
|
|
field :status, Ecto.Enum, values: [:draft, :published, :archived]
|
|
field :metadata, :map, default: %{}
|
|
field :tags, {:array, :string}, default: []
|
|
field :computed, :string, virtual: true # Not persisted
|
|
field :slug, :string, source: :url_slug # Maps to different DB column
|
|
|
|
has_many :reviews, MyApp.Reviews.Review
|
|
has_one :detail, MyApp.Catalog.ProductDetail
|
|
belongs_to :category, MyApp.Catalog.Category
|
|
many_to_many :tags, MyApp.Tag, join_through: "product_tags"
|
|
|
|
embeds_one :seo, SEO, on_replace: :update do
|
|
field :meta_title, :string
|
|
field :meta_description, :string
|
|
end
|
|
|
|
embeds_many :variants, Variant, on_replace: :delete do
|
|
field :sku, :string
|
|
field :price, :decimal
|
|
end
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
def changeset(product, attrs) do
|
|
product
|
|
|> cast(attrs, [:title, :price, :status, :category_id, :metadata, :tags])
|
|
|> cast_embed(:seo)
|
|
|> cast_embed(:variants)
|
|
|> validate_required([:title, :price])
|
|
|> validate_number(:price, greater_than: 0)
|
|
|> validate_length(:title, min: 3, max: 255)
|
|
|> validate_inclusion(:status, [:draft, :published, :archived])
|
|
|> unique_constraint(:title)
|
|
|> foreign_key_constraint(:category_id)
|
|
end
|
|
end
|
|
```
|
|
|
|
**Field types:** `:string`, `:integer`, `:float`, `:decimal`, `:boolean`, `:binary`, `:map`, `{:array, type}`, `:date`, `:time`, `:naive_datetime`, `:utc_datetime`, `:utc_datetime_usec`, `:binary_id`, `Ecto.UUID`, `Ecto.Enum`
|
|
|
|
**Field options:** `:default`, `:source` (DB column name), `:virtual` (not persisted), `:redact` (mask in inspect), `:read_after_writes` (re-read from DB post-write), `:autogenerate`
|
|
|
|
**Embed `:on_replace` options:** `:raise` (default), `:mark_as_invalid`, `:update` (embeds_one), `:delete` (embeds_many)
|
|
|
|
### Changeset — Validation & Change Tracking
|
|
|
|
```elixir
|
|
# Validations (run in-memory, no DB)
|
|
|> validate_required([:field])
|
|
|> validate_format(:email, ~r/@/)
|
|
|> validate_length(:name, min: 2, max: 100)
|
|
|> validate_number(:age, greater_than: 0, less_than: 150)
|
|
|> validate_inclusion(:role, [:admin, :user])
|
|
|> validate_exclusion(:username, ["admin", "root"])
|
|
|> validate_acceptance(:terms) # Checkbox must be true
|
|
|> validate_confirmation(:password) # password_confirmation must match
|
|
|> validate_subset(:permissions, [:read, :write, :admin])
|
|
|> validate_change(:email, fn :email, email -> # Custom per-field validation
|
|
if String.contains?(email, "+"), do: [email: "no plus addressing"], else: []
|
|
end)
|
|
|
|
# Constraints (checked by DB — only run if validations pass)
|
|
|> unique_constraint(:email)
|
|
|> unique_constraint([:user_id, :project_id], name: :user_projects_unique_index)
|
|
|> foreign_key_constraint(:category_id)
|
|
|> check_constraint(:price, name: :price_must_be_positive)
|
|
|> no_assoc_constraint(:reviews) # Prevent delete if has associations
|
|
|> exclusion_constraint(:date_range) # Postgres range exclusion
|
|
```
|
|
|
|
**Schemaless changesets** — validate arbitrary data without a schema:
|
|
```elixir
|
|
types = %{email: :string, age: :integer}
|
|
changeset = {%{}, types}
|
|
|> Ecto.Changeset.cast(params, Map.keys(types))
|
|
|> Ecto.Changeset.validate_required([:email])
|
|
|
|
case Ecto.Changeset.apply_action(changeset, :validate) do
|
|
{:ok, data} -> use_data(data)
|
|
{:error, changeset} -> show_errors(changeset)
|
|
end
|
|
```
|
|
|
|
**Error traversal:**
|
|
```elixir
|
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
end)
|
|
end)
|
|
# => %{title: ["can't be blank"], price: ["must be greater than 0"]}
|
|
```
|
|
|
|
### Query — Composable & Dynamic
|
|
|
|
```elixir
|
|
import Ecto.Query
|
|
|
|
# Composable queries — chain them together
|
|
def published(query \\ Product) do
|
|
from p in query, where: p.status == :published
|
|
end
|
|
|
|
def recent(query, days \\ 7) do
|
|
from p in query, where: p.inserted_at > ago(^days, "day")
|
|
end
|
|
|
|
def with_reviews(query) do
|
|
from p in query, preload: [:reviews]
|
|
end
|
|
|
|
# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()
|
|
```
|
|
|
|
**Named bindings** — track joins without positional counting:
|
|
```elixir
|
|
from p in Post, as: :post,
|
|
join: c in assoc(p, :comments), as: :comment,
|
|
where: as(:comment).approved == true,
|
|
select: {as(:post).title, count(as(:comment).id)},
|
|
group_by: as(:post).id
|
|
```
|
|
|
|
**Dynamic queries** — build conditions programmatically:
|
|
```elixir
|
|
def filter(params) do
|
|
query = from(p in Product)
|
|
|
|
conditions = true
|
|
|
|
conditions = if params["status"],
|
|
do: dynamic([p], p.status == ^params["status"] and ^conditions),
|
|
else: conditions
|
|
|
|
conditions = if params["min_price"],
|
|
do: dynamic([p], p.price >= ^params["min_price"] and ^conditions),
|
|
else: conditions
|
|
|
|
from p in query, where: ^conditions
|
|
end
|
|
```
|
|
|
|
**Subqueries:**
|
|
```elixir
|
|
top_products = from p in Product, order_by: [desc: :sales], limit: 10
|
|
from p in subquery(top_products), select: avg(p.price)
|
|
```
|
|
|
|
**Window functions:**
|
|
```elixir
|
|
from e in Employee,
|
|
select: {e.name, e.salary, over(avg(e.salary), :dept)},
|
|
windows: [dept: [partition_by: e.department_id, order_by: e.salary]]
|
|
```
|
|
|
|
**Fragments (raw SQL):**
|
|
```elixir
|
|
from p in Post,
|
|
where: fragment("? @> ?", p.tags, ^["elixir"]), # Postgres array contains
|
|
order_by: fragment("random()")
|
|
```
|
|
|
|
**Preloading strategies:**
|
|
```elixir
|
|
# Separate queries (default) — parallelized, no data duplication
|
|
Repo.all(Post) |> Repo.preload([:comments, :author])
|
|
|
|
# Join preload — single query, watch for Cartesian product
|
|
from p in Post, join: c in assoc(p, :comments), preload: [comments: c]
|
|
|
|
# Custom query preload
|
|
Repo.preload(posts, comments: from(c in Comment, order_by: c.inserted_at))
|
|
|
|
# Nested preload
|
|
Repo.preload(posts, [comments: [:author, :replies]])
|
|
```
|
|
|
|
**Important:** Use `is_nil(field)` not `field == nil` in queries. Only one `select` per query — use `select_merge` to compose.
|
|
|
|
### Repo — Database Operations
|
|
|
|
```elixir
|
|
# CRUD
|
|
Repo.insert(changeset) # {:ok, struct} | {:error, changeset}
|
|
Repo.update(changeset)
|
|
Repo.delete(struct)
|
|
Repo.insert!(changeset) # Returns struct or raises
|
|
Repo.get(Post, 1) # nil if not found
|
|
Repo.get!(Post, 1) # Raises if not found
|
|
Repo.get_by(Post, title: "Hello") # By arbitrary fields
|
|
Repo.one(query) # Raises if > 1 result
|
|
|
|
# Bulk operations — return {count, nil | results}
|
|
Repo.insert_all(Post, [%{title: "A"}, %{title: "B"}])
|
|
Repo.update_all(from(p in Post, where: p.old == true), set: [archived: true])
|
|
Repo.delete_all(from(p in Post, where: p.inserted_at < ago(1, "year")))
|
|
# NOTE: update_all does NOT update auto-generated fields like updated_at
|
|
|
|
# Upserts (on_conflict)
|
|
Repo.insert(changeset,
|
|
on_conflict: :nothing, # Ignore conflict
|
|
conflict_target: [:email]
|
|
)
|
|
Repo.insert(changeset,
|
|
on_conflict: {:replace, [:name, :updated_at]}, # Update specific fields
|
|
conflict_target: [:email]
|
|
)
|
|
Repo.insert(changeset,
|
|
on_conflict: :replace_all, # Replace everything
|
|
conflict_target: :id
|
|
)
|
|
|
|
# Aggregation
|
|
Repo.aggregate(Post, :count) # SELECT count(*)
|
|
Repo.aggregate(Post, :sum, :views) # SELECT sum(views)
|
|
Repo.aggregate(query, :avg, :price) # Works with queries too
|
|
```
|
|
|
|
### Ecto.Multi — Atomic Transactions
|
|
|
|
Group operations that must all succeed or all fail:
|
|
|
|
```elixir
|
|
alias Ecto.Multi
|
|
|
|
Multi.new()
|
|
|> Multi.insert(:post, Post.changeset(%Post{}, post_attrs))
|
|
|> Multi.insert(:comment, fn %{post: post} ->
|
|
Comment.changeset(%Comment{}, %{post_id: post.id, body: "First!"})
|
|
end)
|
|
|> Multi.update_all(:increment, from(u in User, where: u.id == ^user_id),
|
|
inc: [post_count: 1])
|
|
|> Multi.run(:notify, fn _repo, %{post: post} ->
|
|
# Arbitrary logic — return {:ok, _} or {:error, _}
|
|
Notifications.send_new_post(post)
|
|
end)
|
|
|> Repo.transaction()
|
|
# => {:ok, %{post: %Post{}, comment: %Comment{}, increment: {1, nil}, notify: :sent}}
|
|
# => {:error, failed_op, failed_value, changes_so_far}
|
|
```
|
|
|
|
**Key patterns:** Each operation name must be unique. Failed operations roll back everything. Use `Multi.run/3` for arbitrary logic. Test with `Multi.to_list/1` to inspect without executing.
|
|
|
|
### Streaming — Large Result Sets
|
|
|
|
```elixir
|
|
Repo.transaction(fn ->
|
|
Post
|
|
|> where([p], p.status == :published)
|
|
|> Repo.stream(max_rows: 500)
|
|
|> Stream.each(&process_post/1)
|
|
|> Stream.run()
|
|
end)
|
|
```
|
|
|
|
Must execute within a transaction. Default batch size is 500 rows.
|
|
|
|
### Migrations
|
|
|
|
```elixir
|
|
defmodule MyApp.Repo.Migrations.CreateProducts do
|
|
use Ecto.Migration
|
|
|
|
def change do
|
|
create table("products") do
|
|
add :title, :string, null: false, size: 255
|
|
add :price, :decimal, precision: 10, scale: 2
|
|
add :status, :string, default: "draft"
|
|
add :metadata, :map, default: %{}
|
|
add :tags, {:array, :string}, default: []
|
|
add :category_id, references("categories", on_delete: :restrict)
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
create index("products", [:category_id])
|
|
create unique_index("products", [:title])
|
|
create index("products", [:status, :inserted_at])
|
|
create index("products", [:tags], using: :gin) # Postgres array index
|
|
create index("products", [:title],
|
|
where: "status = 'published'", name: :published_title_idx) # Partial index
|
|
end
|
|
end
|
|
```
|
|
|
|
**Alter tables:**
|
|
```elixir
|
|
def change do
|
|
alter table("products") do
|
|
add :slug, :string
|
|
modify :title, :text, from: :string # :from required for reversibility
|
|
remove :deprecated_field, :string # type required for reversibility
|
|
end
|
|
|
|
# Raw SQL when needed
|
|
execute "CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"",
|
|
"DROP EXTENSION IF EXISTS \"pg_trgm\""
|
|
end
|
|
```
|
|
|
|
**Run migrations:** `mix ecto.migrate` / `mix ecto.rollback` / `mix ecto.reset`
|
|
|
|
### Ecto.Enum
|
|
|
|
```elixir
|
|
# String storage (default)
|
|
field :status, Ecto.Enum, values: [:draft, :published, :archived]
|
|
|
|
# Integer storage
|
|
field :priority, Ecto.Enum, values: [low: 1, medium: 2, high: 3]
|
|
|
|
# Array of enums
|
|
field :roles, {:array, Ecto.Enum}, values: [:admin, :editor, :viewer]
|
|
|
|
# Query helpers
|
|
Ecto.Enum.values(Product, :status) # [:draft, :published, :archived]
|
|
Ecto.Enum.dump_values(Product, :status) # ["draft", "published", "archived"]
|
|
Ecto.Enum.mappings(Product, :status) # [draft: "draft", ...] — for form dropdowns
|
|
```
|
|
|
|
---
|
|
|
|
## Telemetry — Built-in Observability
|
|
|
|
Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.
|
|
|
|
```elixir
|
|
# lib/my_app_web/telemetry.ex (auto-generated)
|
|
defmodule MyAppWeb.Telemetry do
|
|
use Supervisor
|
|
import Telemetry.Metrics
|
|
|
|
def metrics do
|
|
[
|
|
summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
|
|
summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
|
|
summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
|
|
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
|
summary("vm.total_run_queue_lengths.total"),
|
|
summary("vm.total_run_queue_lengths.cpu"),
|
|
]
|
|
end
|
|
end
|
|
```
|
|
|
|
Integrates with **PromEx** for Prometheus/Grafana dashboards (see Part 4: Ecosystem).
|
|
|
|
---
|
|
|
|
## Security Best Practices
|
|
|
|
**Never pass untrusted input to:** `Code.eval_string/3`, `:os.cmd/2`, `System.cmd/3`, `System.shell/2`, `:erlang.binary_to_term/2`
|
|
|
|
**Ecto prevents SQL injection by default** — the query DSL parameterizes all inputs. Only `Ecto.Adapters.SQL.query/4` with raw string interpolation is vulnerable.
|
|
|
|
**Safe deserialization:**
|
|
```elixir
|
|
# UNSAFE — even with :safe flag
|
|
:erlang.binary_to_term(user_input, [:safe])
|
|
|
|
# SAFE — prevents executable terms
|
|
Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])
|
|
```
|
|
|
|
**CSRF protection** is built into the `:browser` pipeline via `protect_from_forgery`. **Content Security Policy** is set by `put_secure_browser_headers`.
|
|
|
|
---
|
|
|
|
## Testing Phoenix
|
|
|
|
```elixir
|
|
# Controller test
|
|
defmodule MyAppWeb.ProductControllerTest do
|
|
use MyAppWeb.ConnCase
|
|
|
|
test "GET /products", %{conn: conn} do
|
|
conn = get(conn, ~p"/products")
|
|
assert html_response(conn, 200) =~ "Products"
|
|
end
|
|
|
|
test "POST /products creates product", %{conn: conn} do
|
|
conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
|
|
assert redirected_to(conn) =~ ~p"/products/"
|
|
end
|
|
end
|
|
|
|
# LiveView test
|
|
defmodule MyAppWeb.CounterLiveTest do
|
|
use MyAppWeb.ConnCase
|
|
import Phoenix.LiveViewTest
|
|
|
|
test "increments counter", %{conn: conn} do
|
|
{:ok, view, html} = live(conn, ~p"/counter")
|
|
assert html =~ "Count: 0"
|
|
|
|
assert view
|
|
|> element("button", "+1")
|
|
|> render_click() =~ "Count: 1"
|
|
end
|
|
end
|
|
|
|
# Channel test
|
|
defmodule MyAppWeb.RoomChannelTest do
|
|
use MyAppWeb.ChannelCase
|
|
|
|
test "broadcasts new messages" do
|
|
{:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")
|
|
|
|
push(socket, "new_msg", %{"body" => "hello"})
|
|
assert_broadcast "new_msg", %{body: "hello"}
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Generators Cheat Sheet
|
|
|
|
```bash
|
|
# HTML CRUD (controller + views + templates + context + schema + migration)
|
|
mix phx.gen.html Catalog Product products title:string price:decimal
|
|
|
|
# LiveView CRUD
|
|
mix phx.gen.live Catalog Product products title:string price:decimal
|
|
|
|
# JSON API
|
|
mix phx.gen.json Catalog Product products title:string price:decimal
|
|
|
|
# Context + schema only (no web layer)
|
|
mix phx.gen.context Catalog Product products title:string price:decimal
|
|
|
|
# Schema + migration only
|
|
mix phx.gen.schema Product products title:string price:decimal
|
|
|
|
# Authentication
|
|
mix phx.gen.auth Accounts User users
|
|
|
|
# Channel
|
|
mix phx.gen.channel Room
|
|
|
|
# Presence
|
|
mix phx.gen.presence
|
|
|
|
# Release files (Dockerfile, release.ex, overlay scripts)
|
|
mix phx.gen.release
|
|
mix phx.gen.release --docker # Include Dockerfile
|
|
```
|
|
|
|
---
|
|
|
|
## Release & Deployment
|
|
|
|
```bash
|
|
# Generate release infrastructure
|
|
mix phx.gen.release --docker
|
|
|
|
# Build release
|
|
MIX_ENV=prod mix deps.get --only prod
|
|
MIX_ENV=prod mix compile
|
|
MIX_ENV=prod mix assets.deploy
|
|
MIX_ENV=prod mix release
|
|
|
|
# Release commands
|
|
_build/prod/rel/my_app/bin/server # Start with Phoenix server
|
|
_build/prod/rel/my_app/bin/migrate # Run migrations
|
|
_build/prod/rel/my_app/bin/my_app remote # Attach IEx console
|
|
```
|
|
|
|
**Runtime config (`config/runtime.exs`):**
|
|
```elixir
|
|
import Config
|
|
|
|
if config_env() == :prod do
|
|
database_url = System.fetch_env!("DATABASE_URL")
|
|
secret_key_base = System.fetch_env!("SECRET_KEY_BASE")
|
|
|
|
config :my_app, MyApp.Repo,
|
|
url: database_url,
|
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
|
|
|
config :my_app, MyAppWeb.Endpoint,
|
|
url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
|
|
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
|
|
secret_key_base: secret_key_base
|
|
end
|
|
```
|