ecto-constraint-debug
Debug Ecto constraint violations - trace triggers, check migrations, find duplicate data. Use when seeing unique_constraint, foreign_key_constraint, or check_constraint errors.
Install
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/plugins/elixir-phoenix/skills/ecto-constraint-debug
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
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 Constraint Debugging
Ash projects: Ash surfaces DB constraints through its own error DSL. Use the
ash-frameworkskill —mix usage_rules.search_docs "constraint" -p ash_postgres.
Systematic approach to diagnosing constraint violations. Load when you see Ecto.ConstraintError, unique_constraint, foreign_key_constraint, or constraint-related changeset errors.
Iron Laws
- READ THE CONSTRAINT NAME — The constraint name (e.g.,
links_url_index) tells you exactly which index/constraint failed. Parse it from the error message first - CHECK MIGRATION BEFORE CODE — Verify the constraint definition in
priv/repo/migrations/matches what the schema expects - TRACE ALL INSERT PATHS — Find every code path that inserts into the constrained table. The bug is often in a path you didn't consider
- RACE CONDITION UNTIL PROVEN OTHERWISE — If validation passes but constraint fails, assume concurrent inserts until you prove a single-request cause
Step-by-Step Debugging
Step 1: Parse the Error
Extract from the error message:
- Constraint name (e.g.,
users_email_index) - Table name (e.g.,
users) - Operation (insert, update, or delete)
- Conflicting values (if available in logs)
Step 2: Find the Migration
Use Grep to search for the constraint name in priv/repo/migrations/. Also check for create unique_index, create index, add constraint.
Verify: Does the migration constraint match the schema's unique_constraint/3 or foreign_key_constraint/3 call?
Step 3: Find the Schema
Use Grep to find constraint handling in changesets (unique_constraint, foreign_key_constraint, check_constraint) in lib/.
Step 4: Trace Insert Paths
Find ALL callers that insert/update this schema:
Use Grep to find all insert/update paths (Repo.insert, Repo.update, Repo.insert_all, cast_assoc) in lib/.
Step 5: Identify the Cause
| Symptom | Likely Cause | Fix Pattern |
|---|---|---|
| Same user triggers twice | Race condition (double-click, retry) | Upsert with on_conflict |
| Multiple parents share child | cast_assoc doesn't dedup across changesets |
Dedup before building changesets |
| Concurrent API requests | Missing transaction isolation | Wrap in Repo.transaction or use upsert |
| Migration added constraint to existing data | Data violates new constraint | Backfill or clean data first |
Step 6: Apply Fix
See ${CLAUDE_SKILL_DIR}/references/constraint-patterns.md for detailed fix patterns.
Quick Fixes by Constraint Type
Unique violation → Upsert: Repo.insert(changeset, on_conflict: :replace_all, conflict_target: [:field])
Foreign key violation → Check: Does the referenced record exist? Was it deleted concurrently?
Check constraint → Validate: Does the value satisfy the constraint condition?
References
${CLAUDE_SKILL_DIR}/references/constraint-patterns.md- Detailed patterns for each constraint type
Files (claude-elixir-phoenix)
-
references
-
constraint-patterns.md 5.1 KB
# Constraint Debugging Patterns ## Unique Constraint Violations ### Pattern 1: Race Condition (Double Submit) **Symptom**: `unique_constraint` error on user action, works on retry. **Root cause**: Two concurrent requests insert the same unique value. **Fix**: Upsert pattern ```elixir def create_or_update_link(attrs) do %Link{} |> Link.changeset(attrs) |> Repo.insert( on_conflict: {:replace, [:updated_at]}, conflict_target: [:url], returning: true ) end ``` ### Pattern 2: Shared Data via `cast_assoc` **Symptom**: Inserting parent records that share child associations fails on the second parent. **Root cause**: `cast_assoc` builds separate INSERT for each parent's children. If two parents reference the same child (e.g., same URL), the second INSERT violates the unique constraint. **Fix**: Deduplicate before building changesets ```elixir # BAD: Each contact gets its own link changesets contacts |> Enum.map(fn contact -> Contact.changeset(contact, %{links: extract_links(contact.text)}) end) # GOOD: Deduplicate links first, then associate all_links = contacts |> Enum.flat_map(&extract_links(&1.text)) |> Enum.uniq_by(& &1.url) {:ok, links} = Repo.insert_all(Link, all_links, on_conflict: :nothing, returning: true) link_map = Map.new(links, &{&1.url, &1.id}) contacts |> Enum.map(fn contact -> link_ids = contact.text |> extract_links() |> Enum.map(&link_map[&1.url]) Contact.changeset(contact, %{link_ids: link_ids}) end) ``` ### Pattern 3: Bulk Insert with Duplicates **Symptom**: `insert_all` fails when input data has duplicate values for a unique column. **Fix**: Deduplicate input or use `on_conflict: :nothing` ```elixir # Deduplicate input unique_records = Enum.uniq_by(records, & &1.email) # Or handle at DB level Repo.insert_all(User, records, on_conflict: :nothing, conflict_target: [:email] ) ``` ## Foreign Key Violations ### Pattern 1: Orphaned Reference **Symptom**: Insert/update fails because referenced record doesn't exist. **Root cause**: Parent record was deleted between validation and insert, or ID was passed incorrectly. **Fix**: Check existence in transaction ```elixir Repo.transact(fn -> case Repo.get(Parent, parent_id) do nil -> {:error, :parent_not_found} parent -> %Child{parent_id: parent.id} |> Child.changeset(attrs) |> Repo.insert() end end) ``` ### Pattern 2: Cascade Delete Surprise **Symptom**: Deleting a parent silently deletes children (or fails if no cascade). **Check migration**: Look for `on_delete` option ```elixir # In migration add :parent_id, references(:parents, on_delete: :delete_all) # CASCADE add :parent_id, references(:parents, on_delete: :restrict) # BLOCK add :parent_id, references(:parents, on_delete: :nilify_all) # SET NULL add :parent_id, references(:parents, on_delete: :nothing) # DB DEFAULT ``` ## Check Constraint Violations ### Pattern 1: Enum Mismatch **Symptom**: Insert fails on check constraint for an Ecto.Enum field. **Root cause**: Value not in the allowed list defined in migration. **Debug**: Compare schema enum values with migration constraint ```elixir # Schema field :status, Ecto.Enum, values: [:draft, :active, :archived] # Migration must match create constraint(:items, :status_must_be_valid, check: "status IN ('draft', 'active', 'archived')") ``` ### Pattern 2: Range Violation **Symptom**: Value fails a range check constraint. **Debug**: Read the constraint definition in migration ```bash grep -r "create constraint.*table_name" priv/repo/migrations/ ``` ## Debugging Techniques ### Inspect the Changeset Error ```elixir case Repo.insert(changeset) do {:ok, record} -> {:ok, record} {:error, changeset} -> # constraint errors appear in changeset.errors IO.inspect(changeset.errors, label: "INSERT ERRORS") IO.inspect(changeset.changes, label: "ATTEMPTED CHANGES") {:error, changeset} end ``` ### Check for Existing Data ```elixir # Find what's violating the unique constraint Repo.all(from r in Record, where: r.unique_field == ^value) ``` ### Trace with Tidewave (when available) ``` mcp__tidewave__execute_sql_query "SELECT * FROM table WHERE unique_col = 'value'" mcp__tidewave__project_eval "MyApp.Repo.all(from r in MyApp.Record, where: r.field == ^value)" ``` ## Prevention Patterns ### Always Handle Constraint Errors ```elixir def create_entity(attrs) do %Entity{} |> Entity.changeset(attrs) |> Repo.insert() |> case do {:ok, entity} -> {:ok, entity} {:error, %{errors: [field: {_, [constraint: :unique]}]}} -> # Handle duplicate gracefully {:error, :already_exists} {:error, changeset} -> {:error, changeset} end end ``` ### Use Upserts for Idempotency ```elixir def upsert_entity(attrs) do %Entity{} |> Entity.changeset(attrs) |> Repo.insert( on_conflict: {:replace, [:name, :updated_at]}, conflict_target: [:external_id], returning: true ) end ``` ### Add Both Validation AND Constraint ```elixir def changeset(entity, attrs) do entity |> cast(attrs, [:email]) |> validate_required([:email]) |> unsafe_validate_unique(:email, MyApp.Repo) # Quick feedback |> unique_constraint(:email) # DB safety end ```
-
-
SKILL.md 3.2 KB
--- name: ecto-constraint-debug description: Debug Ecto constraint violations - trace triggers, check migrations, find duplicate data. Use when seeing unique_constraint, foreign_key_constraint, or check_constraint errors. effort: medium --- # Ecto Constraint Debugging > **Ash projects**: Ash surfaces DB constraints through its own error DSL. Use the `ash-framework` skill — `mix usage_rules.search_docs "constraint" -p ash_postgres`. Systematic approach to diagnosing constraint violations. Load when you see `Ecto.ConstraintError`, `unique_constraint`, `foreign_key_constraint`, or constraint-related changeset errors. ## Iron Laws 1. **READ THE CONSTRAINT NAME** — The constraint name (e.g., `links_url_index`) tells you exactly which index/constraint failed. Parse it from the error message first 2. **CHECK MIGRATION BEFORE CODE** — Verify the constraint definition in `priv/repo/migrations/` matches what the schema expects 3. **TRACE ALL INSERT PATHS** — Find every code path that inserts into the constrained table. The bug is often in a path you didn't consider 4. **RACE CONDITION UNTIL PROVEN OTHERWISE** — If validation passes but constraint fails, assume concurrent inserts until you prove a single-request cause ## Step-by-Step Debugging ### Step 1: Parse the Error Extract from the error message: - **Constraint name** (e.g., `users_email_index`) - **Table name** (e.g., `users`) - **Operation** (insert, update, or delete) - **Conflicting values** (if available in logs) ### Step 2: Find the Migration Use Grep to search for the constraint name in `priv/repo/migrations/`. Also check for `create unique_index`, `create index`, `add constraint`. Verify: Does the migration constraint match the schema's `unique_constraint/3` or `foreign_key_constraint/3` call? ### Step 3: Find the Schema Use Grep to find constraint handling in changesets (`unique_constraint`, `foreign_key_constraint`, `check_constraint`) in `lib/`. ### Step 4: Trace Insert Paths Find ALL callers that insert/update this schema: Use Grep to find all insert/update paths (`Repo.insert`, `Repo.update`, `Repo.insert_all`, `cast_assoc`) in `lib/`. ### Step 5: Identify the Cause | Symptom | Likely Cause | Fix Pattern | |---------|-------------|-------------| | Same user triggers twice | Race condition (double-click, retry) | Upsert with `on_conflict` | | Multiple parents share child | `cast_assoc` doesn't dedup across changesets | Dedup before building changesets | | Concurrent API requests | Missing transaction isolation | Wrap in `Repo.transaction` or use upsert | | Migration added constraint to existing data | Data violates new constraint | Backfill or clean data first | ### Step 6: Apply Fix See `${CLAUDE_SKILL_DIR}/references/constraint-patterns.md` for detailed fix patterns. ## Quick Fixes by Constraint Type **Unique violation** → Upsert: `Repo.insert(changeset, on_conflict: :replace_all, conflict_target: [:field])` **Foreign key violation** → Check: Does the referenced record exist? Was it deleted concurrently? **Check constraint** → Validate: Does the value satisfy the constraint condition? ## References - `${CLAUDE_SKILL_DIR}/references/constraint-patterns.md` - Detailed patterns for each constraint type
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.