agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/ecto-nested-associations/SKILL.md

ecto-nested-associations

Skill4 starsChanged 4 months agoMITView on GitHub

MANDATORY for ALL nested association and multi-table work. Invoke before writing cast_assoc, cast_embed, Ecto.Multi, or cascade operations. Covers nested creates, updates with on_replace, Ecto.Multi for unrelated tables, on_delete strategies, and FK indexes. Trigger words: cast_assoc, cast_embed, Ecto.Multi, nested, association, cascade, on_delete,

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use a join schema with cast_assoc for full control over nested creation and updates:

    line 198
  2. Very specific:

    When updating a nested association with only some fields, preload the association first and rely on Ecto's internal ID matching — do not …

    line 231

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Ecto Nested Associations

RULES — Follow these with no exceptions

1. Use cast_assoc/3 for has_many/has_one — never manually insert children in a separate step 2. Use Ecto.Multi for operations spanning multiple unrelated tables — do NOT use Ecto.Multi for nested associations 3. Set on_delete explicitly in migrations — :delete_all for owned children, :nothing for independent entities 4. Always create indexes on foreign key columns 5. Use on_replace: :delete in cast_assoc for list management 6. Preload associations before updating them — cast_assoc compares against currently loaded data 7. Do NOT require foreign keys in child changesets — cast_assoc sets them automatically 8. Use Repo.transaction/1 with Ecto.Multi — wrap multi-table operations for atomicity

End-to-End Workflow

  1. Identify ownership — determine if children are owned (cascade delete) or independent
  2. Define schema — add has_many/belongs_to with appropriate on_replace strategy
  3. Create migration — add FK column with on_delete and create index
  4. Define changesets — child changeset does NOT require FK field; parent uses cast_assoc
  5. Implement context function — use Repo.insert/Repo.update with parent changeset
  6. Handle results — pattern-match on {:ok, _} and {:error, changeset}
  7. Write tests — test create, update (including removal), and error cases

cast_assoc for Nested Creates

defmodule MyApp.Blog.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :title, :string
    has_many :comments, MyApp.Blog.Comment

    timestamps()
  end

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title])
    |> validate_required([:title])
    |> cast_assoc(:comments, with: &MyApp.Blog.Comment.changeset/2)
  end
end

defmodule MyApp.Blog.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "comments" do
    field :body, :string
    belongs_to :post, MyApp.Blog.Post
    timestamps()
  end

  def changeset(comment, attrs) do
    comment
    |> cast(attrs, [:body])
    |> validate_required([:body])
  end
end

# Usage — create post with comments in one operation
Blog.create_post(%{
  title: "My Post",
  comments: [
    %{body: "First comment"},
    %{body: "Second comment"}
  ]
})

Handling cast_assoc Failures

case Repo.insert(Post.changeset(%Post{}, attrs)) do
  {:ok, post} ->
    {:ok, post}

  {:error, changeset} ->
    # Top-level errors on changeset.errors
    # Nested errors on changeset.changes[:comments] (list of changesets)
    {:error, changeset}
end

cast_assoc for Updates with on_replace

defmodule MyApp.Recipes.Recipe do
  schema "recipes" do
    field :name, :string
    has_many :ingredients, MyApp.Recipes.Ingredient, on_replace: :delete

    timestamps()
  end

  def changeset(recipe, attrs) do
    recipe
    |> cast(attrs, [:name])
    |> validate_required([:name])
    |> cast_assoc(:ingredients, with: &MyApp.Recipes.Ingredient.changeset/2)
  end
end

# Update — send the full list; omitted items are deleted
def update_recipe(recipe, attrs) do
  recipe
  |> Repo.preload(:ingredients)
  |> Recipe.changeset(attrs)
  |> Repo.update()
end

Ecto.Multi for Unrelated Tables

def create_order_with_payment(order_attrs, payment_attrs) do
  Ecto.Multi.new()
  |> Ecto.Multi.insert(:order, Order.changeset(%Order{}, order_attrs))
  |> Ecto.Multi.insert(:payment, fn %{order: order} ->
    Payment.changeset(%Payment{}, Map.put(payment_attrs, :order_id, order.id))
  end)
  |> Repo.transaction()
end

Handling Ecto.Multi Results

case create_order_with_payment(order_attrs, payment_attrs) do
  {:ok, %{order: order, payment: payment}} ->
    {:ok, order}

  {:error, failed_operation, failed_changeset, _changes_so_far} ->
    Logger.error("Multi failed at #{failed_operation}: #{inspect(failed_changeset.errors)}")
    {:error, failed_changeset}
end

on_delete Strategies

defmodule MyApp.Repo.Migrations.CreateComments do
  use Ecto.Migration

  def change do
    create table(:comments) do
      add :body, :text
      add :post_id, references(:posts, on_delete: :delete_all)

      timestamps()
    end

    create index(:comments, [:post_id])
  end
end

Verifying FK Indexes After Migration

Confirm FK indexes exist in psql with \d comments. Expect an entry such as comments_post_id_index. If missing, add it in a new migration:

def change do
  create index(:comments, [:post_id])
end

Many-to-Many Associations

Use a join schema with cast_assoc for full control over nested creation and updates:

# Schema
schema "posts" do
  field :title, :string
  many_to_many :tags, MyApp.Blog.Tag, join_through: MyApp.Blog.PostTag, on_replace: :delete
  timestamps()
end

# Join schema
defmodule MyApp.Blog.PostTag do
  use Ecto.Schema

  schema "post_tags" do
    belongs_to :post, MyApp.Blog.Post
    belongs_to :tag, MyApp.Blog.Tag
    timestamps()
  end
end

# Parent changeset — use cast_assoc with the join schema
def changeset(post, attrs) do
  post
  |> cast(attrs, [:title])
  |> validate_required([:title])
  |> cast_assoc(:post_tags, with: &PostTag.changeset/2)
end

Nested Update with Partial Data

When updating a nested association with only some fields, preload the association first and rely on Ecto's internal ID matching — do not require :id in the child changeset:

def update_post(post, %{post: post_attrs, comments: comments_attrs}) do
  post
  |> Repo.preload(:comments)
  |> Post.changeset(%{post_attrs | comments: comments_attrs})
  |> Repo.update()
end

Common Pitfalls

❌ Don't✅ Do
Manually insert children in a separate stepUse cast_assoc/3 for has_many/has_one
Use Ecto.Multi for nested associationsUse cast_assoc; reserve Ecto.Multi for unrelated tables
Build the update changeset without preloadingRepo.preload/2 before cast_assoc compares data
Require the FK in the child changesetLet cast_assoc set the FK automatically
Omit on_replace on a managed listSet on_replace: :delete to remove omitted items
Leave on_delete unset in the migration:delete_all for owned children, :nothing for independent
Forget the foreign key indexcreate index(:comments, [:post_id])

Integration

PredecessorThis SkillSuccessor
ecto-changeset-patternsecto-nested-associationstesting-essentials
ecto-essentialsecto-nested-associationsapply-ecto-conventions

Companion skills:

  • ecto-essentials — schema, migration, and association basics
  • ecto-changeset-patterns — changeset composition and cast_assoc rules
  • ecto-migration — migration planning for FK and on_delete changes
  • testing-essentials — testing nested creates, updates, and error cases

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.