---
name: ecto-nested-associations
description: Use when a form or operation manages parent and child records together — cast_assoc/cast_embed, on_replace, Ecto.Multi across tables, FK cascade design.
file_patterns:
  - "**/schemas/**/*.ex"
  - "**/*_schema.ex"
auto_suggest: true
---

# 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; let Ecto manage the relationship
2. **Use `Ecto.Multi` for operations spanning multiple unrelated tables** — not nested changesets; Multi provides explicit rollback control
3. **Set `on_delete` explicitly in migrations** — `:delete_all` for owned children, `:nothing` for references to independent entities
4. **Always create indexes on foreign key columns** — missing FK indexes cause slow joins and lookups on the child table
5. **Use `on_replace: :delete` in `cast_assoc` for list management** — allows removing items by omitting them from the input
6. **Preload associations before updating them** — `cast_assoc/3` **raises** (`attempting to cast or change association ... that was not loaded`) if the association isn't preloaded; preload before update

---

## cast_assoc for Nested Creates

Create parent and children in a single operation. Ecto sets foreign keys automatically.

```elixir
# Schema definitions
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

  # Do NOT require :post_id — cast_assoc sets it automatically
  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"}
  ]
})
```

---

## cast_assoc for Updates with on_replace

When updating a has_many, `on_replace: :delete` removes children that are omitted from the input.

```elixir
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)  # MUST preload before cast_assoc
  |> Recipe.changeset(attrs)
  |> Repo.update()
end

# Example: recipe has ingredients A, B, C
# Sending %{ingredients: [%{id: a.id, name: "A"}, %{name: "D"}]}
# Result: A is updated, B and C are deleted, D is created
```

### Why Preloading Matters

```elixir
# Bad — ingredients not preloaded, cast_assoc can't compare
recipe = Repo.get!(Recipe, id)
Recipe.changeset(recipe, attrs)  # ingredients is %Ecto.Association.NotLoaded{}
|> Repo.update()  # Raises: attempting to cast or change association
                  # `ingredients` from `MyApp.Recipes.Recipe` that was not loaded

# Good — preload before updating
recipe = Repo.get!(Recipe, id) |> Repo.preload(:ingredients)
Recipe.changeset(recipe, attrs)  # ingredients is [%Ingredient{}, ...]
|> Repo.update()  # Correctly diffs and applies changes
```

---

## cast_embed for Embedded Schemas

Use `cast_embed` for data stored as JSON in a single column (no separate table).

```elixir
defmodule MyApp.Profiles.Profile do
  use Ecto.Schema
  import Ecto.Changeset

  schema "profiles" do
    field :name, :string
    embeds_many :social_links, SocialLink, on_replace: :delete

    timestamps()
  end

  def changeset(profile, attrs) do
    profile
    |> cast(attrs, [:name])
    |> cast_embed(:social_links, with: &SocialLink.changeset/2)
  end
end

defmodule MyApp.Profiles.Profile.SocialLink do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :platform, :string
    field :url, :string
  end

  def changeset(link, attrs) do
    link
    |> cast(attrs, [:platform, :url])
    |> validate_required([:platform, :url])
    |> validate_format(:url, ~r/^https?:\/\//)
  end
end
```

---

## Ecto.Multi for Multi-Table Operations

When operations span unrelated tables or need explicit control over transaction steps:

```elixir
defmodule MyApp.Orders do
  alias Ecto.Multi

  def place_order(user, cart_items) do
    Multi.new()
    |> Multi.insert(:order, build_order(user))
    |> Multi.insert_all(:line_items, LineItem, fn %{order: order} ->
      Enum.map(cart_items, fn item ->
        %{
          order_id: order.id,
          product_id: item.product_id,
          quantity: item.quantity,
          price: item.price,
          # default timestamps() are :naive_datetime — use DateTime only if
          # the schema uses :utc_datetime
          inserted_at: NaiveDateTime.utc_now(:second),
          updated_at: NaiveDateTime.utc_now(:second)
        }
      end)
    end)
    |> Multi.update(:decrement_stock, fn %{order: _order} ->
      decrement_stock_changeset(cart_items)
    end)
    |> Repo.transaction()
  end
end

# Handling Multi results
case Orders.place_order(user, cart_items) do
  {:ok, %{order: order, line_items: {count, _}, decrement_stock: _}} ->
    # All operations succeeded
    {:ok, order}

  {:error, :order, changeset, _changes_so_far} ->
    # Order insert failed — nothing committed
    {:error, changeset}

  {:error, :decrement_stock, changeset, _changes_so_far} ->
    # Stock update failed — order and line items rolled back
    {:error, changeset}
end
```

### Multi with Run for Custom Logic

```elixir
Multi.new()
|> Multi.run(:validate_stock, fn _repo, _changes ->
  if sufficient_stock?(cart_items) do
    {:ok, :valid}
  else
    {:error, :insufficient_stock}
  end
end)
|> Multi.insert(:order, fn %{validate_stock: :valid} ->
  build_order(user)
end)
|> Repo.transaction()
```

---

## Migration Patterns for Associations

### Foreign Keys with Cascade

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

  def change do
    create table(:comments) do
      add :body, :text, null: false

      # Child — cascade delete when parent is deleted
      add :post_id, references(:posts, on_delete: :delete_all), null: false

      # Reference — don't cascade (user deletion shouldn't delete comments)
      add :user_id, references(:users, on_delete: :nothing), null: false

      timestamps()
    end

    # Always index foreign keys
    create index(:comments, [:post_id])
    create index(:comments, [:user_id])
  end
end
```

### Cascade Decision Guide

```elixir
# :delete_all — child cannot exist without parent
add :comment_id, references(:comments, on_delete: :delete_all)  # Reply → Comment
add :order_id, references(:orders, on_delete: :delete_all), null: false  # LineItem → Order
add :recipe_id, references(:recipes, on_delete: :delete_all), null: false  # Ingredient → Recipe

# :nothing — resource is referenced but independent
add :user_id, references(:users, on_delete: :nothing)  # Post → User
add :category_id, references(:categories, on_delete: :nothing)  # Post → Category

# :nilify_all — remove reference but keep the record
add :team_id, references(:teams, on_delete: :nilify_all)  # User → Team (user keeps account)
```

---

## Foreign Key Indexes

Every `references()` column needs an index. Without it, deleting a parent scans the entire child table.

```elixir
# Bad — foreign key without index
create table(:comments) do
  add :post_id, references(:posts, on_delete: :delete_all)
end
# Deleting a post requires full table scan of comments to find children

# Good — always add an index
create table(:comments) do
  add :post_id, references(:posts, on_delete: :delete_all)
end
create index(:comments, [:post_id])
```

---

## Testing Nested Associations

One representative test — assert `Ecto.Multi` rolls back all steps atomically on failure:

```elixir
describe "place_order/2 with Ecto.Multi" do
  test "rolls back on failure" do
    user = user_fixture()
    items = [%{product_id: -1, quantity: 2, price: 999}]

    assert {:error, _step, _changeset, _changes} =
             Orders.place_order(user, items)
  end
end
```

---

See `ecto-essentials` skill for schema and migration fundamentals.
See `ecto-changeset-patterns` skill for changeset composition and validation.
See `testing-essentials` skill for comprehensive testing patterns.
