Search code examples
ruby-on-railsruby-on-rails-5polymorphic-associationscocoon-gem

belongs_to with polymorphic relationship will not delete from a has_many relationship via nested form


I am attempting to create a quite complex nested form that relies on a polymorphic relationship. I have most of it working, however this one relationship will not perform a delete like I am expecting.

Recipe has many RecipeSteps, RecipeSteps can be polymorphic related to one of three things: Techniques, Steps, or Recipes

For some reason rails refuses to delete the RecipeStep when I attempt to do so from recipes#edit via a nested form and passing _delete: 1 to the RecipeStep. The response is listed at the bottom of the code block below.

I have tried changing the belongs_to associated with the RecipeStep to requires: false, I have tried adding dependent: destroy on anything that would cause a FK error. And I have tried updating the recipes_controller.rb Strong Params to allow all of this info through. I am including :_delete, :id, and all other params RecipeStep requires.

I am not getting any errors, It just does not even attempt to do the delete.

I am using these gems:

  • Cocoon
  • Simple Form

Here is the relevant code:

Polymorphic Relationship Concern

stepable.rb

# frozen_string_literal: true

# Adds the polymorphic relation to recipes through recipe_steps
module Stepable
  extend ActiveSupport::Concern

  included do
    has_many :recipe_steps, as: :stepable
    has_many :recipes, through: :recipe_steps
  end
end

Recipe

recipe.rb - Model

# frozen_string_literal: true
# == Schema Information
#
# Table name: recipes
#
#  id          :bigint(8)        not null, primary key
#  title       :string
#  description :text
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#

# Recipe's are containers that can have as many Steps, Techniques, or even
#   other Recipes within them.
class Recipe < ApplicationRecord
  include Stepable

  has_many :recipe_steps

  has_many :steps,
           through: :recipe_steps,
           source: 'stepable',
           source_type: 'Step',
           dependent: :destroy

  has_many :techniques,
           through: :recipe_steps,
           source: 'stepable',
           source_type: 'Technique',
           dependent: :destroy

  has_many :recipes,
           through: :recipe_steps,
           source: 'stepable',
           source_type: 'Recipe',
           dependent: :destroy

  has_many :ingredients,
           through: :steps

  has_many :step_ingredients,
           through: :steps,
           dependent: :destroy

  accepts_nested_attributes_for :recipes,
                                reject_if: :all_blank,
                                allow_destroy: true

  accepts_nested_attributes_for :techniques,
                                reject_if: :all_blank,
                                allow_destroy: true

  accepts_nested_attributes_for :steps,
                                reject_if: :all_blank,
                                allow_destroy: true

  accepts_nested_attributes_for :recipe_steps,
                                reject_if: :all_blank,
                                allow_destroy: true
end

recipes_controller.rb - Controller

# frozen_string_literal: true

class RecipesController < ApplicationController
  before_action :set_recipe, only: [:show, :edit, :update, :destroy]

  # GET /recipes
  # GET /recipes.json
  def index
    @recipes = Recipe.all.decorate
  end

  # GET /recipes/1
  # GET /recipes/1.json
  def show
  end

  # GET /recipes/new
  def new
    @recipe = Recipe.new
  end

  # GET /recipes/1/edit
  def edit
  end

  # POST /recipes
  # POST /recipes.json
  def create
    @recipe = Recipe.new(recipe_params)

    respond_to do |format|
      if @recipe.save
        format.html { redirect_to @recipe, notice: 'Recipe was successfully created.' }
        format.json { render :show, status: :created, location: @recipe }
      else
        format.html { render :new }
        format.json { render json: @recipe.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /recipes/1
  # PATCH/PUT /recipes/1.json
  def update
    respond_to do |format|
      if @recipe.update(recipe_params)
        format.html { redirect_to @recipe, notice: 'Recipe was successfully updated.' }
        format.json { render :show, status: :ok, location: @recipe }
      else
        format.html { render :edit }
        format.json { render json: @recipe.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /recipes/1
  # DELETE /recipes/1.json
  def destroy
    @recipe.destroy
    respond_to do |format|
      format.html { redirect_to recipes_url, notice: 'Recipe was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_recipe
      @recipe = Recipe.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def recipe_params
      params.require(:recipe)
            .permit(
              :name,
              :title,
              :description,
              recipe_steps_attributes: [
                :id,
                :position,
                :stepable_type,
                :stepable_id,
                :recipe_id,
                :_destroy
              ],
              recipes_attributes: [
                :id,
                :_destroy
              ],
              techniques_attributes: [
                :id,
                :title,
                :description,
                :_destroy
              ],
              steps_attributes: [
                :id,
                :title,
                :description,
                :_destroy,
                step_ingredients_attributes: [
                  :id,
                  :_destroy,
                  :ingredient_id,
                  measurements_attributes: [
                    :id,
                    :unit,
                    :scalar,
                    :purpose,
                    :_destroy
                  ],
                  ingredient_attributes: [
                    :id,
                    :title,
                    :description,
                    :_destroy
                  ]
                ]
              ]
            )
    end
end

recipe_step.rb - Model

# frozen_string_literal: true

# == Schema Information
#
# Table name: recipe_steps
#
#  id            :bigint(8)        not null, primary key
#  recipe_id     :bigint(8)
#  stepable_type :string
#  stepable_id   :bigint(8)
#  position      :integer
#  created_at    :datetime         not null
#  updated_at    :datetime         not null
#

# RecipeStep is the through table for relating polymorphic stepable items to
#   the recipe.
class RecipeStep < ApplicationRecord
  belongs_to :stepable, polymorphic: true, required: false
  belongs_to :recipe, required: false

  before_create :set_position

  accepts_nested_attributes_for :stepable,
                                reject_if: :all_blank,
                                allow_destroy: true

  default_scope -> { order(position: :asc) }

  private

  def set_position
    self.position = recipe.recipe_steps.count + 1
  end
end

Response

Started PATCH "/recipes/3" for ::1 at 2019-02-19 15:55:20 -0500
Processing by RecipesController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"21JWhw/o6ysiInTGv4gJp8pTrvh5jpscpGw2Fhm2o3OyitieRz26nBGeFaZclH21zXHBhjtF7L9ujE3yILl+IQ==", "recipe"=>{"title"=>"Pizza", "description"=>"Three eggs with cilantro, tomatoes, onions, avocados and melted Emmental cheese. With a side of roasted potatoes, and your choice of toast or croissant.", "recipe_steps_attributes"=>{"0"=>{"position"=>"2", "_destroy"=>"1", "id"=>"11"}, "1"=>{"position"=>"10", "_destroy"=>"false", "id"=>"10"}}, "steps_attributes"=>{"0"=>{"title"=>"Ricotta Stuffed Ravioli", "description"=>"Two butter croissants of your choice (plain, almond or cheese). With a side of herb butter or house-made hazelnut spread.", "step_ingredients_attributes"=>{"0"=>{"ingredient_id"=>"1", "measurements_attributes"=>{"0"=>{"unit"=>"", "scalar"=>"0.7", "_destroy"=>"false", "id"=>"15"}, "1"=>{"unit"=>"", "scalar"=>"6.7", "_destroy"=>"false", "id"=>"16"}, "2"=>{"unit"=>"", "scalar"=>"5.8", "_destroy"=>"false", "id"=>"17"}}, "_destroy"=>"false", "id"=>"11"}}, "id"=>"10"}}}, "commit"=>"Save", "id"=>"3"}
  Recipe Load (0.3ms)  SELECT  "recipes".* FROM "recipes" WHERE "recipes"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/recipes_controller.rb:69
   (0.2ms)  BEGIN
  ↳ app/controllers/recipes_controller.rb:46
  RecipeStep Load (0.8ms)  SELECT "recipe_steps".* FROM "recipe_steps" WHERE "recipe_steps"."recipe_id" = $1 AND "recipe_steps"."id" IN ($2, $3) ORDER BY "recipe_steps"."position" ASC  [["recipe_id", 3], ["id", 11], ["id", 10]]
  ↳ app/controllers/recipes_controller.rb:46
  Step Load (1.0ms)  SELECT "steps".* FROM "steps" INNER JOIN "recipe_steps" ON "steps"."id" = "recipe_steps"."stepable_id" WHERE "recipe_steps"."recipe_id" = $1 AND "recipe_steps"."stepable_type" = $2 AND "steps"."id" = $3 ORDER BY "recipe_steps"."position" ASC  [["recipe_id", 3], ["stepable_type", "Step"], ["id", 10]]
  ↳ app/controllers/recipes_controller.rb:46
  StepIngredient Load (0.4ms)  SELECT "step_ingredients".* FROM "step_ingredients" WHERE "step_ingredients"."step_id" = $1 AND "step_ingredients"."id" = $2  [["step_id", 10], ["id", 11]]
  ↳ app/controllers/recipes_controller.rb:46
  Measurement Load (0.4ms)  SELECT "measurements".* FROM "measurements" WHERE "measurements"."step_ingredient_id" = $1 AND "measurements"."id" IN ($2, $3, $4)  [["step_ingredient_id", 11], ["id", 15], ["id", 16], ["id", 17]]
  ↳ app/controllers/recipes_controller.rb:46
   (0.2ms)  COMMIT
  ↳ app/controllers/recipes_controller.rb:46
Redirected to http://localhost:3100/recipes/3
Completed 302 Found in 21ms (ActiveRecord: 3.3ms)

Started GET "/recipes/3" for ::1 at 2019-02-19 15:55:21 -0500
Processing by RecipesController#show as HTML

Important Info: "recipe_steps_attributes"=>{"0"=>{"position"=>"2", "_destroy"=>"1", "id"=>"11"}

As you can see, the _destroy argument is properly set and no un-permitted parameter errors are sent. However there is still not even an attempt to delete the recipe_step

I have not included the views for this error because I believe that they are functioning correctly, as can be seen by the params being passed to the controller. If you think there may be an error with my simple form and or cocoon implementation please ask and I will add those pieces of code.


Solution

  • The issue is that Recipe is related to RecipeStep in two ways:

    1. has_many :recipe_steps
    2. has_many :recipe_steps, as: :stepable via the stepable.rb concern

    Having both of these be :recipe_steps was the issue. I changed the concern to: has_many :through_steps, as: :stepable, class_name: 'RecipeStep' and this solved the deletion issue.