Claude
Skill
ecto-patterns
Use when a task touches Ecto, even a how-to question: schemas, changesets, validations, queries, preloads, Multi, migrations, constraints, money fields. Load it before writing Ecto code. Skip for Ash.
Virus-scanned
Reviewed automatically before listing.
Download
oliver-kriska-claude-elixir-phoenix-plugins_elixir-phoenix_skills_ecto-patterns-9767a82.zip · 11 KB
Install
skills CLI
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/plugins/elixir-phoenix/skills/ecto-patterns
Claude Code
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
Git
git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Ecto Patterns Reference
Reference for working with Ecto schemas, queries, and migrations.
Iron Laws — Never Violate These
- CHANGESETS ARE FOR EXTERNAL DATA — Use
cast/4for user/API input,change/2orput_change/3for internal trusted data - NEVER USE
:floatFOR MONEY — Always use:decimalor:integer(cents) - NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS — They break foreign key constraints; use multiple nullable FKs or separate join tables
- ALWAYS PIN VALUES IN QUERIES —
u.name == ^user_inputis safe, string interpolation causes SQL injection - PRELOAD COLLECTIONS, NOT INDIVIDUALS — Preloading in loops = N+1 queries
- CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS — Validations provide quick feedback, constraints provide DB-level safety
- SEPARATE QUERIES FOR
has_many, JOIN FORbelongs_to— Avoids row multiplication - NO IMPLICIT CROSS JOINS —
from(a in A, b in B)withouton:creates Cartesian product - DEDUP BEFORE
cast_assocWITH SHARED DATA — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset
Quick Schema Template
defmodule MyApp.Context.Entity do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "entities" do
field :name, :string
field :status, Ecto.Enum, values: [:draft, :active, :archived]
field :amount_cents, :integer # Never :float for money!
belongs_to :user, MyApp.Accounts.User
timestamps(type: :utc_datetime_usec)
end
def changeset(entity, attrs) do
entity
|> cast(attrs, [:name, :status, :amount_cents])
|> validate_required([:name])
|> foreign_key_constraint(:user_id)
end
end
Quick Decisions
cast vs put_change vs change
| Function | Use When |
|---|---|
cast/4 |
External data (user input, API) |
put_change/3 |
Internal trusted data (timestamps, computed) |
change/2 |
Internal data from existing struct |
Preload Strategy
| Relationship | Strategy |
|---|---|
belongs_to |
JOIN (single query) |
has_many |
Separate queries (avoid row multiplication) |
Common Anti-patterns
| Wrong | Right |
|---|---|
field :amount, :float |
field :amount_cents, :integer |
"SELECT * WHERE name = '#{name}'" |
from(u in User, where: u.name == ^name) |
Repo.all(User) \|> Enum.filter(& &1.active) |
from(u in User, where: u.active) |
| Preloading in loops | Repo.preload(posts, :comments) |
Repo.get!(User, user_id) with user input |
Repo.get(User, id) + handle nil |
{:ok, _} = Repo.update(cs) inside Repo.transaction |
case/with + Repo.rollback(cs) (see transactions.md) |
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/changesets.md- cast vs put_change, custom validations, prepare_changes${CLAUDE_SKILL_DIR}/references/queries.md- Composable queries, dynamic, subqueries, preloading${CLAUDE_SKILL_DIR}/references/migrations.md- Safe migrations, concurrent indexes, NOT NULL${CLAUDE_SKILL_DIR}/references/transactions.md- Repo.transact, Ecto.Multi, upserts
Files (claude-elixir-phoenix)
-
references
-
changesets.md 4.2 KB
# Changesets Reference ## cast vs put_change vs force_change | Function | Use When | |----------|----------| | `cast/4` | External data (user input, API) | | `put_change/3` | Internal trusted data (timestamps, computed) | | `change/2` | Internal data from existing struct | | `force_change/3` | When you need to set even if value unchanged | ```elixir # External data - use cast def registration_changeset(user, attrs) do user |> cast(attrs, [:email, :password, :name]) |> validate_required([:email, :password, :name]) |> validate_email() |> hash_password() end # Internal data - use put_change defp hash_password(changeset) do case changeset do %{valid?: true, changes: %{password: password}} -> put_change(changeset, :hashed_password, Bcrypt.hash_pwd_salt(password)) _ -> changeset end end ``` ## Multiple Changesets per Schema ```elixir # Different changesets for different operations def registration_changeset(user, attrs) do user |> cast(attrs, [:email, :password, :name]) |> validate_required([:email, :password, :name]) |> validate_email() |> validate_length(:password, min: 12, max: 72) |> hash_password() end def profile_changeset(user, attrs) do user |> cast(attrs, [:name, :bio, :avatar]) |> validate_length(:bio, max: 500) end def password_changeset(user, attrs) do user |> cast(attrs, [:password]) |> validate_required([:password]) |> validate_length(:password, min: 12, max: 72) |> hash_password() end def admin_changeset(user, attrs) do user |> cast(attrs, [:role, :permissions]) |> validate_inclusion(:role, [:user, :moderator, :admin]) end ``` ## Custom Validations ```elixir def changeset(order, attrs) do order |> cast(attrs, [:quantity, :unit_price]) |> validate_required([:quantity, :unit_price]) |> validate_positive_total() end defp validate_positive_total(changeset) do validate_change(changeset, :quantity, fn :quantity, quantity -> unit_price = get_field(changeset, :unit_price) || 0 if quantity * unit_price < 0 do [quantity: "total must be positive"] else [] end end) end ``` ## prepare_changes for Transaction-Safe Operations ```elixir def changeset(post, attrs) do post |> cast(attrs, [:title, :body, :published_at]) |> prepare_changes(fn changeset -> # Runs inside the transaction if get_change(changeset, :published_at) do changeset.repo.update_all( from(p in Post, where: p.author_id == ^post.author_id), inc: [post_count: 1] ) end changeset end) end ``` ## Embedded Schemas **Use embedded_schema when:** - Never query child independently - Never share child across parents - Always loaded with parent (single query) ```elixir # Embedded schema (stored as JSONB) defmodule MyApp.Accounts.Profile do use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :dark_mode, :boolean, default: false field :timezone, :string field :language, :string, default: "en" end def changeset(profile, attrs) do profile |> cast(attrs, [:dark_mode, :timezone, :language]) end end # Parent schema defmodule MyApp.Accounts.User do schema "users" do field :email, :string embeds_one :profile, Profile, on_replace: :update embeds_many :addresses, Address, on_replace: :delete end def changeset(user, attrs) do user |> cast(attrs, [:email]) |> cast_embed(:profile) |> cast_embed(:addresses) end end ``` Migration: ```elixir add :profile, :map add :addresses, :map, default: "[]" ``` ## Field Types | Need | Ecto Type | PostgreSQL | Notes | |------|-----------|------------|-------| | Primary key | `:binary_id` | `uuid` | Prefer UUIDs | | Text | `:string` | `varchar` | Default | | Long text | `:text` | `text` | No limit | | Integer | `:integer` | `integer` | | | Money | `:integer` | `integer` | Store cents (never float!) | | Decimal | `:decimal` | `numeric` | Precise calculations | | Boolean | `:boolean` | `boolean` | | | Date | `:date` | `date` | | | DateTime | `:utc_datetime_usec` | `timestamptz` | With timezone + microseconds | | JSON | `:map` | `jsonb` | Use for dynamic | | Enum | `Ecto.Enum` | `varchar` | Type-safe | | Array | `{:array, :string}` | `varchar[]` | | -
fulltext-search.md 5.6 KB
# PostgreSQL Full-Text Search with Ecto Native full-text search without external dependencies. Based on [Search is Not Magic with PostgreSQL](https://www.codecon.sk/search-is-not-magic-with-postgresql). ## Strategy Decision Tree | Need | Strategy | Extension | |------|----------|-----------| | Exact/weighted text search | Full-text search (tsvector) | Built-in | | Typo tolerance / fuzzy | Trigram similarity (pg_trgm) | `pg_trgm` | | Semantic / AI search | Vector search (pgvector) | `pgvector` | | All of the above | Hybrid with RRF | Multiple | ## 1. Full-Text Search (tsvector/tsquery) ### Migration — Generated Column (Preferred, PostgreSQL 12+) ```elixir def up do execute """ ALTER TABLE articles ADD COLUMN searchable tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(body, '')), 'B') ) STORED; """ create index(:articles, [:searchable], using: :gin) end def down do alter table(:articles) do remove :searchable end end ``` Generated columns auto-update on INSERT/UPDATE — no triggers needed. **When to use triggers instead**: When the tsvector depends on associated records (e.g., tags from a join table). Generated columns can only reference columns in the same row. ### Basic Search Query ```elixir def search_articles(query_string) do from(a in Article, where: fragment( "searchable @@ websearch_to_tsquery('english', ?)", ^query_string ), order_by: [desc: fragment( "ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)", ^query_string )] ) |> Repo.all() end ``` ### Search with Highlights and Pagination ```elixir def search_articles(query_string, opts \\ []) do page = Keyword.get(opts, :page, 1) per_page = Keyword.get(opts, :per_page, 20) from(a in Article, where: fragment("searchable @@ websearch_to_tsquery('english', ?)", ^query_string), select: %{ id: a.id, title: a.title, headline: fragment( "ts_headline('english', ?, websearch_to_tsquery('english', ?), 'StartSel=<mark>, StopSel=</mark>')", a.body, ^query_string ), rank: fragment("ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)", ^query_string) }, order_by: [desc: fragment("ts_rank_cd(searchable, websearch_to_tsquery('english', ?), 32)", ^query_string)], offset: ^((page - 1) * per_page), limit: ^per_page ) |> Repo.all() end ``` ### Multi-Language Support ```elixir # Dynamic language via regconfig casting from(a in Article, where: fragment( "to_tsvector(?::text::regconfig, ?) @@ to_tsquery(?::text::regconfig, ?)", ^language, a.body, ^language, ^query ) ) ``` ### websearch_to_tsquery Syntax (Google-style) | Input | Matches | |-------|---------| | `elixir phoenix` | Both words | | `"exact phrase"` | Exact phrase | | `elixir OR phoenix` | Either word | | `-deprecated` | Excludes word | ### Weight Meanings | Weight | Use | Boost | |--------|-----|-------| | A | Title | Highest | | B | Subtitles | High | | C | Body | Medium | | D | Metadata | Lower | ## 2. Trigram Similarity (pg_trgm) — Fuzzy/Typo Tolerance ```elixir # Migration execute "CREATE EXTENSION IF NOT EXISTS pg_trgm" execute "CREATE INDEX products_name_trgm_idx ON products USING gin(name gin_trgm_ops)" # Query from(p in Product, where: fragment("similarity(?, ?) > ?", p.name, ^term, 0.3), order_by: [desc: fragment("similarity(?, ?)", p.name, ^term)] ) ``` Trigrams compare 3-character groups — handles typos, misspellings, partial matches. Threshold 0.3 is a good default; tune based on your data. ## 3. Hybrid Search with RRF (Reciprocal Rank Fusion) Combine multiple search strategies by normalizing ranks: ```elixir # Each strategy returns %{id, rank} with: 1 / (60 + distance) similarity_results = search_by_trigram(term) fulltext_results = search_by_tsvector(term) # Merge with deduplication (similarity_results ++ fulltext_results) |> Enum.group_by(& &1.id) |> Enum.map(fn {id, ranks} -> {id, Enum.sum(Enum.map(ranks, & &1.rank))} end) |> Enum.sort_by(&elem(&1, 1), :desc) ``` ### Multi-Word Query Normalization ```elixir defp normalize_query(text) do text |> String.trim() |> String.downcase() |> String.replace(~r/\s+/, " ") |> String.split(" ") |> Enum.join(" & ") end ``` ## Performance ```elixir # ALWAYS use GIN index for tsvector create index(:articles, [:searchable], using: :gin) # Partial index for large tables create index(:articles, [:searchable], using: :gin, where: "published_at IS NOT NULL") # GIN indexes only used with LIMIT — always paginate ``` ## Anti-patterns ```elixir # WRONG: Computing tsvector at query time (slow, no index!) from(a in Article, where: fragment("to_tsvector('english', title || ' ' || body) @@ to_tsquery(?)", ^q) ) # WRONG: Using LIKE for search (no ranking, no stemming) from(a in Article, where: ilike(a.title, ^"%#{query}%")) # WRONG: Using triggers when generated columns suffice # Generated columns are simpler and auto-maintained # WRONG: Assuming PG can't do fuzzy search # pg_trgm handles typo tolerance natively — no need for Elasticsearch just for fuzzy ``` ## When to Use External Search PostgreSQL handles most use cases (100K-10M docs). Consider Meilisearch/Elasticsearch for: - Faceted search with complex filters across many dimensions - Multi-language with mixed alphabets in same field - Real-time indexing of 10M+ documents - Advanced search analytics **Further reading**: [Search is Not Magic with PostgreSQL](https://www.codecon.sk/search-is-not-magic-with-postgresql) — covers trigrams, full-text, vector search, and hybrid patterns with Ecto examples. -
migrations.md 4.5 KB
# Migrations Reference ## Basic Migration ```elixir defmodule MyApp.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users, primary_key: false) do add :id, :binary_id, primary_key: true add :email, :string, null: false add :name, :string add :role, :string, default: "user" timestamps(type: :utc_datetime_usec) end create unique_index(:users, [:email]) end end ``` ## Add Foreign Key (Safe - 2 steps) ```elixir # Step 1: Add without validation (fast, no table lock) def change do alter table(:posts) do add :user_id, references(:users, type: :binary_id, validate: false), null: false end create index(:posts, [:user_id]) end # Step 2: Validate in separate migration (separate deploy) def change do execute "ALTER TABLE posts VALIDATE CONSTRAINT posts_user_id_fkey", "" end ``` ## Add NOT NULL (Safe - 3 steps) ```elixir # Step 1: Add check constraint without validation def change do create constraint("products", :active_not_null, check: "active IS NOT NULL", validate: false ) end # Step 2: Backfill data (separate deploy) def change do execute "UPDATE products SET active = false WHERE active IS NULL", "" end # Step 3: Validate constraint, add NOT NULL, drop constraint def change do execute "ALTER TABLE products VALIDATE CONSTRAINT active_not_null", "" execute "ALTER TABLE products ALTER COLUMN active SET NOT NULL", "" drop constraint("products", :active_not_null) end ``` ## Pre-Migration Safety: Check Data BEFORE Unique Index A unique index on a table with existing duplicates fails at deploy time — in the worst case mid-release. Check FIRST, and include soft-deleted rows: they're invisible in the app but still block the index. ```sql -- Find duplicates the index would reject (run before writing the migration) SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1; -- Soft-deleted rows count too — check whether they collide SELECT email, COUNT(*) FILTER (WHERE deleted_at IS NOT NULL) AS deleted, COUNT(*) FILTER (WHERE deleted_at IS NULL) AS live FROM users GROUP BY email HAVING COUNT(*) > 1; ``` Resolutions, in order of preference: 1. **Partial index** when soft-deleted rows may legitimately collide: `create unique_index(:users, [:email], where: "deleted_at IS NULL")` 2. **Data-fix migration first** (separate deploy), then the index 3. **Composite key** if the duplicates are actually valid scoping (e.g., unique per tenant: `unique_index(:users, [:org_id, :email])`) Same discipline applies before adding NOT NULL (check for NULLs) and before foreign keys (check for orphaned rows). ## Concurrent Index (Large Tables) ```elixir @disable_ddl_transaction true @disable_migration_lock true def change do create index(:posts, [:slug], concurrently: true) end ``` ## Batched Data Migration ```elixir def change do # For large tables, process in batches execute &migrate_data/0, &rollback_data/0 end defp migrate_data do repo().transaction(fn -> from(u in "users", where: is_nil(u.status), select: u.id, limit: 1000) |> repo().all() |> Enum.each(&update_user_status/1) end) end ``` ## Mixed Primary Key Types (bigint + binary_id) When integrating libraries that use UUID PKs (Sagents, Oban, etc.) while your project uses bigint: ```elixir # WRONG: Global @foreign_key_type affects ALL associations @foreign_key_type :binary_id # RIGHT: Explicit type ONLY on specific associations schema "interviews" do belongs_to :user, MyApp.Accounts.User # bigint (default) belongs_to :conversation, Agents.Conversation, type: :binary_id end ``` In migrations, match the referenced table's PK type: ```elixir alter table(:interviews) do add :user_id, references(:users, type: :bigint), null: false add :conversation_id, references(:conversations, type: :binary_id) end ``` ## Associations ```elixir # One-to-many (always specify on_delete!) has_many :posts, Post, on_delete: :delete_all belongs_to :user, User # Many-to-many many_to_many :tags, Tag, join_through: "post_tags", on_replace: :delete # Has one through has_one :organization, through: [:user, :organization] # Self-referential belongs_to :parent, __MODULE__ has_many :children, __MODULE__, foreign_key: :parent_id ``` ## Optimistic Locking ```elixir schema "products" do field :name, :string field :lock_version, :integer, default: 1 end def changeset(product, attrs) do product |> cast(attrs, [:name]) |> optimistic_lock(:lock_version) end # Usage - raises Ecto.StaleEntryError if version changed Repo.update!(changeset) ``` -
queries.md 4.9 KB
# Queries Reference ## Composable Query Functions ```elixir defmodule MyApp.Posts.PostQuery do import Ecto.Query def base, do: from(p in Post, as: :post) def published(query \\ base()) do from p in query, where: not is_nil(p.published_at) end def by_author(query \\ base(), author_id) do from p in query, where: p.author_id == ^author_id end def recent(query \\ base(), days \\ 7) do cutoff = Date.add(Date.utc_today(), -days) from p in query, where: p.inserted_at >= ^cutoff end def ordered(query \\ base(), direction \\ :desc) do from p in query, order_by: [{^direction, p.inserted_at}] end end # Usage: Pipeline composition PostQuery.base() |> PostQuery.published() |> PostQuery.by_author(author_id) |> PostQuery.ordered() |> Repo.all() ``` ## Dynamic Queries ```elixir def filter_where(params) do Enum.reduce(params, dynamic(true), fn {"author", value}, dynamic -> dynamic([p], ^dynamic and p.author == ^value) {"category", value}, dynamic -> dynamic([p], ^dynamic and p.category == ^value) {"title_contains", value}, dynamic -> dynamic([p], ^dynamic and ilike(p.title, ^"%#{value}%")) {_, _}, dynamic -> dynamic # Ignore unknown params end) end # Usage def list_posts(params) do Post |> where(^filter_where(params)) |> Repo.all() end ``` ## Subqueries ```elixir # Correlated subquery with parent_as comment_count = from c in Comment, where: parent_as(:post).id == c.post_id, select: count() from p in Post, as: :post, select: %{title: p.title, comment_count: subquery(comment_count)} ``` ## Window Functions ```elixir from p in Post, select: %{ id: p.id, title: p.title, row_num: row_number() |> over(partition_by: p.category_id, order_by: p.inserted_at), rank: rank() |> over(partition_by: p.category_id, order_by: [desc: p.view_count]) } ``` ## JSONB Queries (Ecto 3.12+) ### json_extract_path/2 Extract values from JSONB columns without raw SQL: ```elixir # Extract nested JSON value from u in User, where: json_extract_path(u.settings, ["notifications", "email"]) == true, select: json_extract_path(u.metadata, ["theme"]) # Compare with older fragment approach (still works but verbose) from u in User, where: fragment("?->>'email' = ?", u.settings["notifications"], "true") ``` ### JSONB Pattern: Filtering on Nested Keys ```elixir # Dynamic key extraction def by_metadata_key(query, key, value) do from u in query, where: json_extract_path(u.metadata, ^[key]) == ^value end # Array access in JSONB from p in Product, where: json_extract_path(p.attributes, ["tags", 0]) == "featured" ``` ### JSONB Anti-patterns ```elixir # WRONG: Loading all rows then filtering in Elixir Repo.all(User) |> Enum.filter(fn u -> u.settings["notifications"]["email"] == true end) # RIGHT: Filter in database from u in User, where: json_extract_path(u.settings, ["notifications", "email"]) == true # TIP: For frequently queried JSONB paths, create expression index # In migration: # execute "CREATE INDEX users_settings_notifications_idx ON users ((settings->'notifications'))" ``` ## Repo.reload Options (Ecto 3.12+) ```elixir # Basic reload (unchanged) user = Repo.reload!(user) # With preloads (new in 3.12) user = Repo.reload!(user, preload: [:posts, :comments]) # Force fresh query (skip query cache) user = Repo.reload!(user, force: true) ``` ## Full-Text Search For PostgreSQL full-text search patterns, see `${CLAUDE_SKILL_DIR}/references/fulltext-search.md`. ## Preload Strategies ```elixir # Separate queries (default) - BEST for has_many # Two queries: posts + comments Repo.preload(post, :comments) # Join (single query) - BEST for belongs_to/has_one # One query with JOIN - watch for row multiplication with has_many! from(p in Post, preload: [:author]) # Custom query for filtered/ordered preloads Repo.preload(post, comments: from(c in Comment, order_by: c.inserted_at, limit: 10)) ``` ## Pagination ```elixir def list_posts(params \\ %{}) do page = Map.get(params, :page, 1) per_page = Map.get(params, :per_page, 20) from(p in Post, order_by: [desc: p.inserted_at], offset: ^((page - 1) * per_page), limit: ^per_page ) |> Repo.all() end ``` ## Anti-patterns ```elixir # WRONG: N+1 queries users = Repo.all(User) Enum.map(users, fn u -> u.posts end) # N queries! # RIGHT: Preload users = Repo.all(User) |> Repo.preload(:posts) # WRONG: Getting all then filtering in Elixir Repo.all(User) |> Enum.filter(& &1.active) # RIGHT: Filter in query from(u in User, where: u.active) |> Repo.all() # WRONG: String interpolation (SQL injection!) from(u in User, where: fragment("name = '#{name}'")) # RIGHT: Parameterized queries from(u in User, where: u.name == ^name) # WRONG: Using Repo.get! with user input (may raise) Repo.get!(User, user_provided_id) # RIGHT: Handle not found case Repo.get(User, id) do nil -> {:error, :not_found} user -> {:ok, user} end ``` -
transactions.md 4.4 KB
# Transactions Reference ## Repo.transact (Simpler for most cases) ```elixir Repo.transact(fn -> with {:ok, user} <- create_user(params), {:ok, profile} <- create_profile(user) do {:ok, user} end end) ``` ## Repo.transaction/1: changeset error handling Inside the callback form `Repo.transaction(fn -> ... end)`, a bare `{:ok, _} =` match on a write is a footgun. When the write returns `{:error, changeset}`, the match raises `MatchError`; the transaction rolls back and **re-raises** it, so the caller crashes (a `500` in a request) and the changeset's validation errors are lost. It does *not* return `{:error, %MatchError{}}` — nothing catches it for you. ```elixir # BAD — invalid changeset raises MatchError, which propagates and crashes the caller Repo.transaction(fn -> {:ok, updated} = Repo.update(changeset) updated end) # GOOD — explicit case + Repo.rollback/1 preserves the changeset Repo.transaction(fn -> case Repo.update(changeset) do {:ok, updated} -> updated {:error, changeset} -> Repo.rollback(changeset) end end) # => {:error, %Ecto.Changeset{}} — caller gets actionable validation errors ``` The same applies to `Repo.insert/1` and `Repo.delete/1`. For multiple steps, use `with ... else` and roll back on the error path: ```elixir Repo.transaction(fn -> with {:ok, user} <- Repo.insert(user_changeset), {:ok, profile} <- Repo.insert(profile_changeset(user)) do profile else {:error, changeset} -> Repo.rollback(changeset) end end) ``` Prefer `Repo.transact/1` (above) or `Ecto.Multi` (below) when you can — both surface `{:error, ...}` on failure without a manual `Repo.rollback/1`, steering you away from the bare match. This footgun is specific to the classic `Repo.transaction/1` callback form. (Ties to the plugin Iron Laws on checking changeset errors and matching `{:error, %Ecto.Changeset{}}` explicitly.) ## Ecto.Multi (Complex operations, testing) ```elixir alias Ecto.Multi # Basic Multi Multi.new() |> Multi.insert(:user, user_changeset) |> Multi.insert(:profile, fn %{user: user} -> Profile.changeset(%Profile{user_id: user.id}, %{}) end) |> Multi.run(:welcome_email, fn _repo, %{user: user} -> MyApp.Mailer.deliver_welcome(user) end) |> Repo.transaction() # Composition with merge Multi.new() |> Multi.insert(:order, order_changeset) |> Multi.merge(fn %{order: order} -> Multi.new() |> Multi.insert_all(:items, OrderItem, build_items(order)) end) |> Repo.transaction() # Reusable Multi components (default to Multi.new()) def transfer_money(multi \\ Multi.new(), from_id, to_id, amount) do multi |> Multi.run(:validate, fn _, _ -> validate_accounts(from_id, to_id) end) |> Multi.update(:debit, fn _ -> debit_changeset(from_id, amount) end) |> Multi.update(:credit, fn _ -> credit_changeset(to_id, amount) end) end # Error handling case Repo.transaction(multi) do {:ok, %{user: user, team: team}} -> {:ok, user} {:error, :user, changeset, _changes} -> {:error, :user_creation_failed, changeset} {:error, :team, changeset, _changes} -> {:error, :team_creation_failed, changeset} end # Testing Multi without DB multi = PasswordManager.reset(account, params) assert [{:account, {:update, changeset, []}}] = Ecto.Multi.to_list(multi) ``` ## Upsert Patterns ```elixir # Insert or update on conflict Repo.insert( changeset, on_conflict: {:replace, [:name, :updated_at]}, conflict_target: :external_id ) # Insert or do nothing Repo.insert(changeset, on_conflict: :nothing, conflict_target: :email) # Insert all with upsert Repo.insert_all( Post, posts, on_conflict: {:replace_all_except, [:id, :inserted_at]}, conflict_target: :external_id ) ``` ## Batch Operations ```elixir # insert_all (fast bulk insert) Repo.insert_all(Post, posts, returning: [:id]) # update_all (fast bulk update) from(p in Post, where: p.status == :draft) |> Repo.update_all(set: [status: :archived, updated_at: DateTime.utc_now()]) # delete_all (fast bulk delete) from(p in Post, where: p.inserted_at < ^cutoff) |> Repo.delete_all() ``` ## Streaming ```elixir # For processing large result sets without loading all into memory Repo.transaction(fn -> from(p in Post) |> Repo.stream() |> Stream.each(&process_post/1) |> Stream.run() end) ``` ## Connection Pool Tuning ```elixir # config/runtime.exs config :my_app, MyApp.Repo, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), queue_target: 50, queue_interval: 1000 ``` Rule of thumb: `pool_size = (CPU cores * 2) + disk spindles`
-
-
SKILL.md 3.6 KB
--- name: ecto-patterns description: "Use when a task touches Ecto, even a how-to question: schemas, changesets, validations, queries, preloads, Multi, migrations, constraints, money fields. Load it before writing Ecto code. Skip for Ash." effort: medium user-invocable: false paths: - "**/migrations/*.exs" - "**/*_schema.ex" - "**/*changeset*.ex" --- # Ecto Patterns Reference Reference for working with Ecto schemas, queries, and migrations. ## Iron Laws — Never Violate These 1. **CHANGESETS ARE FOR EXTERNAL DATA** — Use `cast/4` for user/API input, `change/2` or `put_change/3` for internal trusted data 2. **NEVER USE `:float` FOR MONEY** — Always use `:decimal` or `:integer` (cents) 3. **NO RAILS-STYLE POLYMORPHIC ASSOCIATIONS** — They break foreign key constraints; use multiple nullable FKs or separate join tables 4. **ALWAYS PIN VALUES IN QUERIES** — `u.name == ^user_input` is safe, string interpolation causes SQL injection 5. **PRELOAD COLLECTIONS, NOT INDIVIDUALS** — Preloading in loops = N+1 queries 6. **CONSTRAINTS BEAT VALIDATIONS FOR RACE CONDITIONS** — Validations provide quick feedback, constraints provide DB-level safety 7. **SEPARATE QUERIES FOR `has_many`, JOIN FOR `belongs_to`** — Avoids row multiplication 8. **NO IMPLICIT CROSS JOINS** — `from(a in A, b in B)` without `on:` creates Cartesian product 9. **DEDUP BEFORE `cast_assoc` WITH SHARED DATA** — When multiple parents share child data, deduplicate child records BEFORE building changesets. Dedup only works within a single changeset ## Quick Schema Template ```elixir defmodule MyApp.Context.Entity do use Ecto.Schema import Ecto.Changeset @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "entities" do field :name, :string field :status, Ecto.Enum, values: [:draft, :active, :archived] field :amount_cents, :integer # Never :float for money! belongs_to :user, MyApp.Accounts.User timestamps(type: :utc_datetime_usec) end def changeset(entity, attrs) do entity |> cast(attrs, [:name, :status, :amount_cents]) |> validate_required([:name]) |> foreign_key_constraint(:user_id) end end ``` ## Quick Decisions ### cast vs put_change vs change | Function | Use When | |----------|----------| | `cast/4` | External data (user input, API) | | `put_change/3` | Internal trusted data (timestamps, computed) | | `change/2` | Internal data from existing struct | ### Preload Strategy | Relationship | Strategy | |--------------|----------| | `belongs_to` | JOIN (single query) | | `has_many` | Separate queries (avoid row multiplication) | ## Common Anti-patterns | Wrong | Right | |-------|-------| | `field :amount, :float` | `field :amount_cents, :integer` | | `"SELECT * WHERE name = '#{name}'"` | `from(u in User, where: u.name == ^name)` | | `Repo.all(User) \|> Enum.filter(& &1.active)` | `from(u in User, where: u.active)` | | Preloading in loops | `Repo.preload(posts, :comments)` | | `Repo.get!(User, user_id)` with user input | `Repo.get(User, id)` + handle nil | | `{:ok, _} = Repo.update(cs)` inside `Repo.transaction` | `case`/`with` + `Repo.rollback(cs)` (see transactions.md) | ## References For detailed patterns, see: - `${CLAUDE_SKILL_DIR}/references/changesets.md` - cast vs put_change, custom validations, prepare_changes - `${CLAUDE_SKILL_DIR}/references/queries.md` - Composable queries, dynamic, subqueries, preloading - `${CLAUDE_SKILL_DIR}/references/migrations.md` - Safe migrations, concurrent indexes, NOT NULL - `${CLAUDE_SKILL_DIR}/references/transactions.md` - Repo.transact, Ecto.Multi, upserts
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.