Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Access grandparent of unsaved parent and child in Rails

I have a form to save a Parent and many Child objects together. During the initialization of the Child object, it needs access to the Grandparent. Here's what the models looks like:

class Grandparent
  has_many :parents, inverse_of: :grandparent
end

class Parent
  belongs_to :grandparent, inverse_of: :parents
  has_many :children, inverse_of: :parent
  accepts_nested_attributes_for :children
end

class Child
  belongs_to :parent
  delegate :grandparent, to: :parent

  # Test code
  after_initialize do
    raise 'NoParentError' unless parent.present?
    raise 'NoGrandparentError' unless grandparent.present? # Errors here!
    puts 'All good!'
  end
end

Remember, the form is for saving a new parent and many children simultaneously, but I'm trying to access information in the grandparent object. I read that the inverse_of options should have done the trick, but child.grandparent is still nil unfortunately.

Here's the piece of the controller that's actually causing the failure:

@parent = @grandparent.parents.build(parent_params)
# prior to saving @parent

For some reason the parent has no idea who the grandparent is.

Update

It looks like I can get past that error with this code:

@parent = Parent.new(parent_params.merge(grandparent: @grandparent))

But that doesn't seem very "railsy" to me.

Update 2

As requested, here is my controller for the form.

class ParentsController < ApplicationController
  before_action :set_grandparent
  def new
    @parent = @grandparent.parents.new
    @parent.children.build
  end

  def create
    @parent = @grandparent.parents.build(parent_params)
    if @parent.save
      redirect_to @grandparent
    else
      render :new
    end
  end

  private

  def set_grandparent
    @grandparent = Grandparent.find(params[:grandparent_id])
  end

  def parent_params
    params.require(:parent).permit(:parent_attribute,
                                   children_attributes: [:some_attribute, :other_attribute, :id, :_destroy]
  end
end

Here's what my view looks like:

= simple_form_for [@grandparent, @parent] do |f|
  = f.input :parent_attribute
  = f.simple_fields_for :children do |child_form|
    = child_form.input :some_attribute
    = child_form.input :other_attribute
  = f.submit

I am able to place a byebug in the after_initialize code of the Child and I'm able to see the unsaved Parent and the Child and can access them with:

p = self.parent
=> Parent object

p.grandparent
=> nil

self.grandparent
=> nil
like image 387
ardavis Avatar asked Jan 04 '18 01:01

ardavis


2 Answers

This problem occurs because the instance of Parent is initialized before it gets added to (associated with) the instance of Grandparent. Let me illustrate that to you with the following example:

class Grandparent < ApplicationRecord
  # before_add and after_add are two callbacks specific to associations
  # See: http://guides.rubyonrails.org/association_basics.html#association-callbacks
  has_many :parents, inverse_of: :grandparent,
           before_add: :run_before_add, after_add: :run_after_add

  # We will use this to test in what sequence callbacks/initializers are fired
  def self.test
    @grandparent = Grandparent.first

    # Excuse the poor test parameters -- I set up a bare Rails project and
    # did not define any columns, so created_at and updated_at was all I
    # had to work with
    parent_params =
      {
        created_at: 'now',
        children_attributes: [{created_at: 'test'}]
      }

    # Let's trigger the chain of initializations/callbacks
    puts 'Running initialization callback test:'
    @grandparent.parents.build(parent_params)
  end

  # Runs before parent object is added to this instance's #parents
  def run_before_add(parent)
    puts "before adding parent to grandparent"
  end

  # Runs after parent object is added to this instance's #parents
  def run_after_add(parent)
    puts 'after adding parent to grandparent'
  end
end

class Parent < ApplicationRecord
  belongs_to :grandparent, inverse_of: :parents
  has_many :children, inverse_of: :parent,
           before_add: :run_before_add, after_add: :run_after_add
  accepts_nested_attributes_for :children

  def initialize(attributes)
    puts 'parent initializing'
    super(attributes)
  end

  after_initialize do
    puts 'after parent initialization'
  end

  # Runs before child object is added to this instance's #children    
  def run_before_add(child)
    puts 'before adding child'
  end

  # Runs after child object is added to this instance's #children
  def run_after_add(child)
    puts 'after adding child'
  end
end

class Child < ApplicationRecord
  # whether it's the line below or
  # belongs_to :parent, inverse_of: :children
  # makes no difference in this case -- I tested :)
  belongs_to :parent
  delegate :grandparent, to: :parent

  def initialize(attributes)
    puts 'child initializing'
    super(attributes)
  end

  after_initialize do
    puts 'after child initialization'
  end
end

Running the method Grandparent.test from Rails console outputs this:

Running initialization callback test:
parent initializing
child initializing
after child initialization
before adding child
after adding child
after parent initialization
before adding parent to grandparent
after adding parent to grandparent

What you can see from this is that the parent is not actually added to grandparent until the very end. In other words, parent does not know about grandparent until the child initialization and its own initialization are over.

If we modify each puts statement to include grandparent.present?, we get the following output:

Running initialization callback test:
parent initializing: n/a
child initializing: n/a
after child initialization: false
before adding child: false
after adding child: false
after parent initialization: true
before adding parent to grandparent: true
after adding parent to grandparent: true

So you could do the following to initialize parent by itself first and initialize child(ren) afterwards:

class Parent < ApplicationRecord
  # ...
  def initialize(attributes)
    # Initialize parent but don't initialize children just yet
    super attributes.except(:children_attributes)

    # Parent initialized. At this point grandparent is accessible!
    # puts grandparent.present? # true!

    # Now initialize children. MUST use self
    self.children_attributes = attributes[:children_attributes]
  end
  # ...
end

Here is what that outputs when running Grandparent.test like:

Running initialization callback test:
before parent initializing: n/a
after parent initialization: true
child initializing: n/a
after child initialization: true
before adding child: true
after adding child: true
before adding parent to grandparent: true
after adding parent to grandparent: true

As you can see, parent initialization now runs and completes before invoking child initialization.

But explicitly passing grandparent: @grandparent into the params hash may be the easiest solution.

When you explicitly specify grandparent: @grandparent in the params hash you pass to @grandparent.parents.build, grandparent is initialized from the very beginning. Probably because all attributes are processed as soon as the #initialize method runs. Here is what that looks like:

Running initialization callback test:
parent initializing: n/a
child initializing: n/a
after child initialization: true
before adding child: true
after adding child: true
after parent initialization: true
before adding parent to grandparent: true
after adding parent to grandparent: true

You could even call merge(grandparent: @grandparent) directly in your controller method #parent_params, like so:

def parent_params
    params.require(:parent).permit(
      :parent_attribute,
      children_attributes: [
        :some_attribute, 
        :other_attribute, 
        :id, 
        :_destroy
      ]
    ).merge(grandparent: @grandparent)
end

PS: Apologies for the overly long answer.

like image 191
Finn Avatar answered Oct 16 '22 12:10

Finn


I've hit this issue before in a scenario similar to yours. The problem is Rails fails to initialize the grandparent relationship on the newly built parent prior to assigning the nested attributes for the children. Here's a quick fix:

# make sure the `grandparent` association is established on
# the `parent` _before_ assigning nested attributes
@parent = @grandparent.parents.build()
@parent.assign_attributes(parent_params)

If this solution gives you a bad feeling, you might consider dumping accepts_nested_attributes_for in favor of extracting an explicit form object, over which you'll have more control. This post gives a good example in section 3 "Extract Form Objects", albeit the exact implementation is a bit outdated. See this question for a more up-to-date implementation (although instead of Virtus, you might use the Rails 5 attributes API).

like image 1
w_hile Avatar answered Oct 16 '22 14:10

w_hile