0

I have a deeply nested attributes controller, like this:

  def update
    @product = Product.find(params[:id])
    authorize @product

    if @product.update(product_params)
      render status: :no_content
    else
      render json: { errors: @product.errors }, status: :unprocessable_entity
    end
  end

  private

  def product_params
    params.require(:product).permit(
        :name, :description, :product_type, :category, :fabric_and_care,
        billing_plans_attributes: [
          :id, :_destroy, :updated_at, :name, :amount, :color, :size, :currency, :interval, :interval_count, :intervals_to_bill, :inventory_type, :usage_type, :active, :sold_out, :sibling_coupon_id, :third_sibling_coupon_id, :renewal_plan_id,
          :vendor_sku_id, :vendor_price,
          billing_plan_add_ons_attributes: [:id, :_destroy, :upsell_id],
          photos_attributes: [:id, :_destroy]
        ]
    ).merge(team: current_profile.team)
  end

In particular this issue is with the billing_plan_add_ons_attributes and the photos_attributes that are nested inside billing_plans_attributes.

The product model looks like this:

has_many :billing_plans, dependent: :destroy, inverse_of: :product
accepts_nested_attributes_for :billing_plans, allow_destroy: true
has_many :photos, through: :billing_plans
has_many :billing_plan_add_ons, through: :billing_plans

BillingPlan:


  belongs_to :product, inverse_of: :billing_plans
  has_many :billing_plan_add_ons, class_name: "BillingPlanAddOn", dependent: :destroy, inverse_of: :billing_plan
  accepts_nested_attributes_for :billing_plan_add_ons, allow_destroy: true
  has_many :upsells, through: :billing_plan_add_ons
  has_many :photos, as: :attachable, class_name: "Attachment", dependent: :destroy, inverse_of: :attachable
  accepts_nested_attributes_for :photos, allow_destroy: true

Everything works independently; however, when I send both a photos payload and a billing plan add on payload, the billing plan add ons are skipped.

Example payload that skips destroying the add on:

        product: {
          billing_plans_attributes: [
            {
              id: product.billing_plans.first.id,
              photos_attributes: [
                {
                  id: product.photos.last.id,
                  _destroy: false
                }
              ],
              billing_plan_add_ons_attributes: [
                {
                  id: add_on.id,
                  _destroy: true
                },
              ],
            }
          ]
        }

More info, per comments. Apologies for not including this earlier.

Here is a spec that shows the issue:

it "allows for modifying billing plan add on attributes, with photos staying the same" do
      # Team and upsell are declared like this in top level definition
      # let(:upsell) { FactoryBot.create(:upsell, team: team) }
      # let(:team) { user.active_profile!.team }
      product = FactoryBot.create(:product, :for_online_store, team: team)
      billing_plan = product.billing_plans.first
      add_on = FactoryBot.create(:billing_plan_add_on, billing_plan: billing_plan, upsell: upsell)
      photo = billing_plan.photos.last
      expect(product.photos.size).to eq(1)
      expect(product.upsells.size).to eq(1)

      params = {
        product: {
          billing_plans_attributes: [
            {
              id: billing_plan.id,
              updated_at: Time.now.iso8601,
              photos_attributes: [
                {
                  id: photo.id,
                  _destroy: false,
                }
              ],
              billing_plan_add_ons_attributes: [
                {
                  id: add_on.id,
                  _destroy: true,
                },
              ]
            }
          ]
        }
      }
      put api_v1_product_path(product), params: params.to_json, headers: { authorization: "Bearer #{auth_token}", 'Content-Type': "application/json", accept: "application/json" }
      expect(response.status).to eq(204)
      product.reload
      expect(product.photos.size).to eq(1)
      expect { add_on.reload }.to raise_error(ActiveRecord::RecordNotFound)
    end

That will fail on the add_on.reload expect to raise record not found step.

Removing the photos_attributes payload from that spec turns it green, so it seems like some interaction between having both of those payloads in at the same time.

Digging a bit deeper into this code, we appear to have an unusual attributes setter for photos as well. This is due to this issue in Rails. In the app the user is uploading straight to S3 to create attachments so we have an existing id, without a type, created before this nested controller is hit.

  # This setter is due to ActiveRecord not handling the situation of a new record accepting photos_attributes
  # hash that contains an existing record key.
  # See https://github.com/rails/rails/issues/7256
  # This one is also weird, in that billing plans photos are typically created through products. So we need to often duplicate product images.
  def photos_attributes=(attributes)
    new_attachment_map = {}
    self.photos = attributes.map do |attachment_attrs|
      dup_attachment = Attachment.find(attachment_attrs['id']).dup
      dup_attachment.save
      new_attachment_map[attachment_attrs['id']] = dup_attachment.id
      dup_attachment
    end

    new_attributes = attributes.map do |attributes|
      attributes['id'] = new_attachment_map[attributes['id']]
      attributes
    end
    super(new_attributes)
  end

I think I can work around this for now, but if anyone has been in this situation, I'd love to hear how you handled it.

The params are all correct when logged. In the Rails log it appears to find the billing_plan_add_ons with a select statement, but then skips over updating it for some reason.

3
  • There is nothing obvious in the code here which explains it. Have you tried adding a breakpoint and checking the return value of product_params? This would let you isolate if the problem is in the params whitelisting or in attribute assignment in the model. guides.rubyonrails.org/… Commented Apr 24, 2024 at 6:47
  • 1
    It can also help if you write an integration test and share it with us so that there are actual repeatable steps to recreate the issue. Commented Apr 24, 2024 at 6:49
  • Apologies, I should have added that in the first place. I've updated it to include a spec + an unusual setter that is probably causing issues here. Commented Apr 24, 2024 at 16:11

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.