Search code examples
ruby-on-railsactiverecordassociationsruby-on-rails-6model-associations

Nested association not saving


I have a nested association for a customer. The setup is like this:

  • A customer has one address
  • An address has a physical_address
  • An address has a postal_addrsss

Address model

  belongs_to :customer, optional: true # Yes, optional
  
  has_one :physical_address, dependent: :destroy
  has_one :postal_address, dependent: :destroy

  accepts_nested_attributes_for :physical_address
  accepts_nested_attributes_for :postal_address

Postal Address model

# same for physical_address.rb
belongs_to :address

Customer controller:

def create
  @customer = current_user.customers.build(customer_params)

  if @customer.save
    return puts "customer saves"
  end

  puts @customer.errors.messages
  #redirect_to new_customer_path
  render :new
end

private
  def customer_params
    params.require(:customer).permit(
      address_attributes: address_params
    )
  end

  def address_params
    return ([
      postal_address_attributes: shared_address_params,
      #physical_address_attributes: shared_address_params
    ])
  end

  def shared_address_params
    params.fetch(:customer).fetch("address").fetch("postal_address").permit(
      :street, etc...
    )
  end

Customer model:

has_one     :address, dependent: :destroy
accepts_nested_attributes_for :address

A customer is created ok but not the address. Here's the form, for example:

<form>
  <input name="customer[address][postal_address][street]" value="Foo Street" />
</form>

Logging "params", I see all the values but address is not creating. I believe the error lies in shared_address_params. Any ideas?


Solution

  • I think you just managed to lose yourself in layers of indirection and complexity in that parameters whitelist.

    What you basically want is:

    def customer_params
      params.require(:customer)
            .permit(
              address_attributes: {
                physical_address_attributes: [:street, :foo, :bar, :baz],
                postal_address: [:street, :foo, :bar, :baz]
              }
            )
    end
    

    As you can see here you need the param key customer[address_attributes] not just customer[address].

    Now lets refactor to cut the duplication:

    def customer_params
      params.require(:customer)
            .permit(
              address_attributes: {
                physical_address_attributes: address_attributes,
                postal_address: address_attributes
              }
            )
    end
    
    def address_attributes
      [:street, :foo, :bar, :baz]
    end
    

    As you can see there should be very little added complexity here any and if you need to make it more flexible add arguments to the address_attributes method - after all building the whitelist is just simple array and hash manipulation.

    If you want to handle mapping some sort of shared attributes to the two address types you really should do it in the model instead of bloating the controller with business logic. Like for example by creating setters and getters for a "virtual attribute":

    class Address < ApplicationController
      def shared_address_attributes
        post_address_attributes.slice("street", "foo", "bar", "baz")
      end
    
      def shared_address_attributes=(**attrs)
        # @todo map the attributes to the postal and
        # visiting address
      end
    end
    

    That way you would just setup the form and whitelist it like any other attribute and the controller doesn't need to be concerned with the nitty gritty details.

    def customer_params
      params.require(:customer)
            .permit(
              address_attributes: {
                shared_address_attributes: address_attributes,
                physical_address_attributes: address_attributes,
                postal_address: address_attributes
              }
            )
    end