ID of parent record is not filled in inside associated record with cast_assoc when trying to update

I am trying to update a Course 's CourseTierGroups, which is a join table with an extra property, order.

This is the structure of the update:

%{
  "course_tier_groups" => [
    %{
      "id" => 1,
      "order" => 1,
      "tier_group" => %{
        "description" => "Fuga...",
        "id" => 1,
        "tag" => "tgatque_et",
        "tiers" => [],
        "title" => "TG-Hic aliquid quas cupiditate accusantium nam et quos minus tempora."
      }
    },
    %{
      "id" => 2,
      "order" => 0,
      "tier_group" => %{
        "description" => "Nihil...",
        "id" => 2,
        "tag" => "tgharum_maxime_vero_animi_ad",
        "tiers" => [],
        "title" => "TG-Et sunt perferendis consequatur."
      }
    }
  ]
}

This is the CourseTierGroup schema:

schema "course_tier_group" do
  belongs_to(:course, Course)
  belongs_to(:tier_group, TierGroup)
  field(:order, :integer)
  timestamps()
end

And changeset:

def changeset(%CourseTierGroup{} = struct, params \\ %{}) do
  struct
  |> Repo.preload([:tier_group])
  |> cast(params, [:order])
  |> validate_required([:order])
  |> cast_assoc(:tier_group, required: true, with: &TierGroup.changeset/2)
  |> unique_constraint([:course_id, :tier_group_id])
  |> unique_constraint([:course_id, :order])
end

This is the relationship in the Course schema:

schema "course" do
...
    has_many(:course_tier_groups, CourseTierGroup, on_replace: :delete)
...
end

And this is the Course changeset:

def changeset(%Course{} = struct, params \\ %{}) do
    struct
    |> Repo.preload([:scopes, :type, :course_tier_groups, :topic_pills])
    |> cast(params, [:title, :description, :tag, :type_metadata])
    |> put_scopes
    |> put_type
    |> put_topic_pills
    |> cast_assoc(:course_tier_groups, with: &CourseTierGroup.changeset/2)
    |> validate_required([:title, :description, :tag])
    |> unique_constraint([:tag])
  end

In the original data, the order is switched around (id = 1 has order 0 and id = 2 has order 1).

The tier group itself is not changed, which is picked up correctly by Ecto. It also detects that I am trying to update the order. However, from what I’ve read, I was expecting the course’s ID in course_tier_groups to be filled in by Ecto, but it doesn’t do it.

It gives me the following error due to the fact that course_id is null and there already exists a record with course_id = nil and order = 1 (The one before the update):

#Ecto.Changeset<
  action: :update,
  changes: %{
    course_tier_groups: [
      #Ecto.Changeset<
        action: :update,
        changes: %{course_id: nil, order: 1},
        errors: [
          course_id: {"has already been taken",
           [
             constraint: :unique,
             constraint_name: "course_tier_group_course_id_order_index"
           ]}
        ],
        data: #LLServer.Schema.CourseTierGroup<>,
        valid?: false
      >,
      #Ecto.Changeset<
        action: :update,
        changes: %{order: 0},
        errors: [],
        data: #LLServer.Schema.CourseTierGroup<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: #LLServer.Schema.Course<>,
  valid?: false
>

I am expecting course_id to be filled in with the ID of the course that is being updated.

How should I deal with this? Should I also pass course_id in my update map?

The issue here is that cast_assoc/3 attempts to create a new associated record. If you already have the record created you’ll want to use put_assoc/4 Instead. Alternatively you can just set the id of the associated record if you don’t have it loaded from the database already and want to avoid the extra query.

1 Like

The problem with that is that I cannot pass the ID without ecto complaining on update operations that a Postgres index is not unique, due to transactions not being deferrable. See Error while working with DEFERRED foreign key constraint (Postgres) · Issue #2394 · elixir-ecto/ecto · GitHub

I ended up solving it by not allowing the update of nested records and just handling it all on its own API route, it’s much more work but it’s the only way I’ve managed to make it work with Ecto. Maybe Elixir isn’t the best language for complex CRUD APIs or I’m just not knowledgeable enough on admin panel architecture design, most likely the latter since this is my first time building a rather complex one while trying to stick to REST principles.