AgentSkillsCN

ecto-patterns

Phoenix/Elixir 应用中的 Ecto 模式。涵盖 Schema、Changeset、迁移、查询、Ecto.Multi、事务、约束、关联、分页、租户分区、性能优化以及测试等主题。

SKILL.md
--- frontmatter
name: ecto-patterns
description: Ecto patterns for Phoenix/Elixir apps. Covers schemas, changesets, migrations, queries, Ecto.Multi, transactions, constraints, associations, pagination, tenant partitioning, performance, and testing.
version: 1.0.0
category: toolchain
author: Claude MPM Team
license: MIT
progressive_disclosure:
  entry_point:
    summary: "Ecto schemas/changesets, migrations, queries, transactions, constraints, pagination, tenancy, and performance for Phoenix apps."
    when_to_use:
      - "Designing schemas and changesets with validation/constraints"
      - "Running migrations safely (DDL ordering, locks) and managing data migrations"
      - "Executing transactional flows with Ecto.Multi"
      - "Implementing pagination, preloading, and multi-tenant partitioning"
    quick_start:
      - "mix ecto.create && mix ecto.migrate"
      - "Define schemas + changesets; enforce DB constraints (unique/foreign keys)"
      - "Use Repo.transaction / Ecto.Multi for multi-step writes"
      - "Preload associations; paginate with limit/offset or cursor libs (Scrivener/Flop)"
  token_estimate:
    entry: 170
    full: 5400

Ecto Patterns for Phoenix/Elixir

Ecto is the data layer for Phoenix applications: schemas, changesets, queries, migrations, and transactions. Good Ecto practice keeps domain logic in contexts, enforces constraints in the database, and uses transactions for multi-step workflows.

Schemas and Changesets

elixir
defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :hashed_password, :string
    field :confirmed_at, :naive_datetime
    has_many :memberships, MyApp.Orgs.Membership
    timestamps()
  end

  def registration_changeset(user, attrs) do
    user
    |> cast(attrs, [:email, :password])
    |> validate_required([:email, :password])
    |> validate_format(:email, ~r/@/)
    |> validate_length(:password, min: 12)
    |> unique_constraint(:email)
    |> hash_password()
  end

  defp hash_password(%{valid?: true} = cs),
    do: put_change(cs, :hashed_password, Argon2.hash_pwd_salt(get_change(cs, :password)))
  defp hash_password(cs), do: cs
end

Guidelines

  • Keep casting/validation in changesets; keep business logic in contexts.
  • Always pair validation with DB constraints (unique_constraint, foreign_key_constraint).
  • Use changeset/2 for updates; avoid mass assigning without casting.

Migrations

elixir
def change do
  create table(:users) do
    add :email, :citext, null: false
    add :hashed_password, :string, null: false
    add :confirmed_at, :naive_datetime
    timestamps()
  end

  create unique_index(:users, [:email])
end

Safe migration tips

  • Prefer additive changes: add columns nullable, backfill, then enforce null: false.
  • For large tables: use concurrently: true for indexes; disable in change and wrap in up/down for Postgres.
  • Data migrations belong in separate modules called from mix ecto.migrate via execute/1 or in distinct scripts; ensure idempotence.
  • Coordinate locks: avoid long transactions; break migrations into small steps.

Queries and Preloads

elixir
import Ecto.Query

def list_users(opts \\ %{}) do
  base =
    from u in MyApp.Accounts.User,
      preload: [:memberships],
      order_by: [desc: u.inserted_at]

  Repo.all(apply_pagination(base, opts))
end

defp apply_pagination(query, %{limit: limit, offset: offset}),
  do: query |> limit(^limit) |> offset(^offset)
defp apply_pagination(query, _), do: query

Patterns

  • Use preload rather than calling Repo in loops; prefer Repo.preload/2 after fetching.
  • Use select to avoid loading large blobs.
  • For concurrency, use Repo.transaction with lock: "FOR UPDATE" in queries that need row-level locks.

Transactions and Ecto.Multi

elixir
alias Ecto.Multi

def onboard_user(attrs) do
  Multi.new()
  |> Multi.insert(:user, User.registration_changeset(%User{}, attrs))
  |> Multi.insert(:org, fn %{user: user} ->
    Org.changeset(%Org{}, %{owner_id: user.id, name: attrs["org_name"]})
  end)
  |> Multi.run(:welcome, fn _repo, %{user: user} ->
    MyApp.Mailer.deliver_welcome(user)
    {:ok, :sent}
  end)
  |> Repo.transaction()
end

Guidelines

  • Prefer Multi.run/3 for side effects that can fail; return {:ok, value} or {:error, reason}.
  • Use Multi.update_all for batch updates; include where guards to prevent unbounded writes.
  • Propagate errors upward; translate them in controllers/LiveViews.

Associations and Constraints

  • Use on_replace: :delete/:nilify to control nested changes.
  • Define foreign_key_constraint/3 and unique_constraint/3 in changesets to surface DB errors cleanly.
  • For many-to-many, prefer join schema (has_many :memberships) instead of automatic many_to_many when you need metadata.

Pagination and Filtering

  • Offset/limit for small datasets; cursor-based for large lists (Scrivener, Flop, Paginator).
  • Normalize filters in contexts; avoid letting controllers build queries directly.
  • Add composite indexes to match filter columns; verify with EXPLAIN ANALYZE.

Multi-Tenancy Patterns

  • Prefix-based: Postgres schemas per tenant (put_source/2 with prefix:) — good isolation, needs per-tenant migrations.
  • Row-based: tenant_id column + row filters — simpler migrations; add partial indexes per tenant when large.
  • Always scope queries by tenant in contexts; consider using policies/guards to enforce.

Performance and Ops

  • Use Repo.stream for large exports; wrap in Repo.transaction.
  • Cache hot reads with ETS/Cachex; invalidate on writes.
  • Watch query counts in LiveView/Channels; preload before rendering to avoid N+1.
  • Telemetry: OpentelemetryEcto exports query timings; add DB connection pool metrics.

Testing

elixir
use MyApp.DataCase, async: true

test "registration changeset validates email" do
  changeset = User.registration_changeset(%User{}, %{email: "bad", password: "secretsecret"})
  refute changeset.valid?
  assert %{email: ["has invalid format"]} = errors_on(changeset)
end
  • DataCase sets up sandboxed DB; keep tests async unless transactions conflict.
  • Use factories/fixtures in test/support to build valid structs quickly.
  • For migrations, add regression tests for constraints (unique/index-backed constraints).

Common Pitfalls

  • Running risky DDL in a single migration step (avoid locks; break apart).
  • Skipping DB constraints and relying only on changesets.
  • Querying associations in loops instead of preloading.
  • Missing transactions for multi-step writes (partial state on failure).
  • Forgetting tenant scoping on read/write in multi-tenant setups.