访问 Rails 中未保存的 parent 和 child 的 grandparent

Access grandparent of unsaved parent and child in Rails

我有一个表格可以将 Parent 和许多 Child object 保存在一起。在 Child object 的初始化期间,它需要访问 Grandparent。以下是模型的外观:

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

请记住,该表格用于同时保存一个新的 parent 和许多 children,但我正在尝试访问 grandparent object 中的信息。我读到 inverse_of 选项应该可以解决问题,但不幸的是 child.grandparent 仍然是 nil

这是实际导致故障的控制器部分:

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

出于某种原因,parent 不知道谁是 grandparent。

更新

看来我可以使用此代码克服该错误:

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

但这对我来说似乎不是很 "railsy"。

更新 2

根据要求,这是我的表单控制器。

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

这是我的观点:

= 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

我能够在 Childafter_initialize 代码中放置一个 byebug 并且我能够看到未保存的 ParentChild 并且可以通过以下方式访问它们:

p = self.parent
=> Parent object

p.grandparent
=> nil

self.grandparent
=> nil

出现此问题是因为 Parent 的实例在添加到(关联)Grandparent 的实例之前被初始化。让我用以下示例向您说明这一点:

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

运行 来自 Rails 控制台的方法 Grandparent.test 输出:

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

从这里可以看出,parent 直到最后才真正添加到 grandparent 中。换句话说,parent直到child初始化和它自己的初始化结束后才知道grandparent。

如果我们修改每个 puts 语句以包含 grandparent.present?,我们将得到以下输出:

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

所以你可以先自己初始化parent然后再初始化child(ren):

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

这是 运行 Grandparent.test 时的输出:

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

如您所见,parent 初始化现在运行并在调用 child 初始化之前完成。

但显式将 grandparent: @grandparent 传递到参数散列中可能是最简单的解决方案。

当您在传递给 @grandparent.parents.build 的参数哈希中明确指定 grandparent: @grandparent 时,grandparent 从一开始就被初始化。可能是因为 #initialize 方法一运行就处理了所有属性。这是它的样子:

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

您甚至可以在控制器方法 #parent_params 中直接调用 merge(grandparent: @grandparent),如下所示:

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

PS: 很抱歉回答太长。

一定要after_initialize吗?

如果你可以使用 before_create 代替,那么你应该可以在回调方法中访问 grandparent

您也可以使用 before_validation(:method, on: :create)。这将允许您添加一些验证以确保 Child 设置正确。此外,您可以在代码的其他地方调用 child.valid? 来设置一个子对象而不保存它。

我以前在与您类似的情况下遇到过这个问题。问题是 Rails 无法在新建的 parent 上初始化 grandparent 关系 children 分配嵌套属性之前.这是一个快速修复:

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

如果此解决方案给您带来不好的感觉,您可以考虑放弃 accepts_nested_attributes_for 以支持提取显式表单对象,您可以对其进行更多控制。 This post gives a good example in section 3 "Extract Form Objects", albeit the exact implementation is a bit outdated. See for a more up-to-date implementation (although instead of Virtus, you might use the Rails 5 attributes API).