Implementing a has many through relationship - But I must be blind

Hi everyone,

I’ve been struggling with an Ecto error while trying to associate projects with a task in my project management app. When I attempt to save a task with associated projects, I encounter the following error:

(ArgumentError) cannot put assoc projects, assoc projects not found. Make sure it is spelled correctly and that the association type is not read-only

Context:

Projects:

    has_many :project_tasks, TaskManager.TaskProject
    has_many :tasks, through: [:project_tasks, :task]

  alias TaskManager.Repo

  schema "tasks" do
    field :name, :string
    field :description, :string
    field :status, Ecto.Enum, values: [:backlog, :not_started, :in_progress, :completed], default: :backlog
    field :position, :integer, default: 0
    field :deadline, :date
    field :duration, :integer, default: 0
    field :progress, :integer, default: 0
    field :priority, Ecto.Enum, values: [:low, :medium, :high, :critical], default: :medium

    belongs_to :workspace, TaskManager.Workspace
    belongs_to :kanban_column, TaskManager.KanbanColumn, foreign_key: :kanban_column_id, on_replace: :nilify
    belongs_to :user, TaskManager.Accounts.User
    belongs_to :parent, __MODULE__, foreign_key: :parent_id
    has_many :children, __MODULE__, foreign_key: :parent_id
    has_many :task_projects, TaskManager.TaskProject
    has_many :projects, through: [:task_projects, :project]

    timestamps()
  end

  def changeset(task, attrs) do
    task
    |> cast(attrs, [
      :name,
      :description,
      :status,
      :kanban_column_id,
      :position,
      :user_id,
      :deadline,
      :duration,
      :progress,
      :priority,
      :parent_id,
      :workspace_id
    ])
    |> validate_required([:name, :user_id, :workspace_id])
    |> validate_inclusion(:progress, 0..100)
    |> validate_change(:deadline, fn _, deadline ->
      if deadline && Date.compare(deadline, Date.utc_today()) == :lt do
        [deadline: "cannot be in the past"]
      else
        []
      end
    end)
    |> validate_circular_dependency()
    |> assoc_constraint(:kanban_column)
    |> assoc_constraint(:user)
    |> assoc_constraint(:parent)
    |> put_assoc(:projects, Map.get(attrs, "projects", []))
  end

In live view:

  def handle_event("save_task_projects", %{"task_id" => task_id, "project_ids" => project_ids}, socket) do
    task_id = String.to_integer(task_id)
    project_ids = Enum.map(project_ids, &String.to_integer/1)

    case Tasks.assign_projects_to_task(task_id, project_ids) do
      {:ok, updated_task} ->
        updated_task = Repo.preload(updated_task, :projects) 
        send(self(), {:task_updated, updated_task})
        {:noreply, stream_insert(socket, :tests, updated_task)}
    
      {:error, reason} ->
        IO.inspect(reason, label: "Assign Projects Error")
        {:noreply, put_flash(socket, :error, "Failed to assign projects to task.")}
    end
  end

Table:

defmodule TaskManager.TaskProject do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  schema "projects_tasks" do
    belongs_to :project, TaskManager.Projects.Project, primary_key: true
    belongs_to :task, TaskManager.Tasks.Task, primary_key: true

    timestamps()
  end

  def changeset(task_project, attrs) do
    task_project
    |> cast(attrs, [:project_id, :task_id])
    |> validate_required([:project_id, :task_id])
    |> assoc_constraint(:project)
    |> assoc_constraint(:task)
  end
end

Migration:

defmodule TaskManager.Repo.Migrations.CreateProjectsTasksJoinTable do
  use Ecto.Migration

  def change do
    create table(:projects_tasks, primary_key: false) do
      add :task_id, references(:tasks, on_delete: :delete_all), primary_key: true
      add :project_id, references(:projects, on_delete: :delete_all), primary_key: true

      timestamps()
    end
  end
end

The error occurs when I try to save a task with projects in my LiveView. For example:

  • I select a task and attempt to assign it to one or more projects using a dropdown form.
  • When I submit the form, the error is thrown.

Thank you very much in advance!

From the has_many docs:

Note :through associations are read-only. For example, you cannot use Ecto.Changeset.cast_assoc/3 to modify through associations.

What you likely want instead is to put_assoc a new set of TaskProjects with the selected project IDs.

2 Likes

Jesus, yepp I was blind. Many thanks! Been struggling for three days.

Have a great weekend!

Solved with @al2o3cr suggestion:

 def handle_event("save_task_projects", %{"task_id" => task_id, "project_ids" => project_ids}, socket) do
    task_id = String.to_integer(task_id)
    project_ids = Enum.map(project_ids, &String.to_integer/1)
  
    case Tasks.assign_projects_to_task(task_id, project_ids) do
      {:ok, updated_task} ->

        updated_task = Repo.preload(updated_task, :projects)
        
        updated_tasks =
          Enum.map(socket.assigns.tasks, fn task ->
            if task.id == updated_task.id, do: updated_task, else: task
          end)
  
        {:noreply, assign(socket, :tasks, updated_tasks)}
  
      {:error, reason} ->
        IO.inspect(reason, label: "Assign Projects Error")
        {:noreply, put_flash(socket, :error, "Failed to assign projects to task.")}
    end
end
1 Like