install
source · Clone the upstream repo
git clone https://github.com/MacPhobos/research-mind
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/MacPhobos/research-mind "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/toolchains-elixir-data-ecto-patterns" ~/.claude/skills/macphobos-research-mind-toolchains-elixir-data-ecto-patterns && rm -rf "$T"
manifest:
.claude/skills/toolchains-elixir-data-ecto-patterns/skill.mdsource content
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
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
for updates; avoid mass assigning without casting.changeset/2
Migrations
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
for indexes; disable inconcurrently: true
and wrap inchange
for Postgres.up/down - Data migrations belong in separate modules called from
viamix ecto.migrate
or in distinct scripts; ensure idempotence.execute/1 - Coordinate locks: avoid long transactions; break migrations into small steps.
Queries and Preloads
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
rather than calling Repo in loops; preferpreload
after fetching.Repo.preload/2 - Use
to avoid loading large blobs.select - For concurrency, use
withRepo.transaction
in queries that need row-level locks.lock: "FOR UPDATE"
Transactions and Ecto.Multi
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
for side effects that can fail; returnMulti.run/3
or{:ok, value}
.{:error, reason} - Use
for batch updates; includeMulti.update_all
guards to prevent unbounded writes.where - Propagate errors upward; translate them in controllers/LiveViews.
Associations and Constraints
- Use
/on_replace: :delete
to control nested changes.:nilify - Define
andforeign_key_constraint/3
in changesets to surface DB errors cleanly.unique_constraint/3 - For many-to-many, prefer join schema (
) instead of automatichas_many :memberships
when you need metadata.many_to_many
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 (
withput_source/2
) — good isolation, needs per-tenant migrations.prefix: - Row-based:
column + row filters — simpler migrations; add partial indexes per tenant when large.tenant_id - Always scope queries by tenant in contexts; consider using policies/guards to enforce.
Performance and Ops
- Use
for large exports; wrap inRepo.stream
.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:
exports query timings; add DB connection pool metrics.OpentelemetryEcto
Testing
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
sets up sandboxed DB; keep tests async unless transactions conflict.DataCase- Use factories/fixtures in
to build valid structs quickly.test/support - 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.