Ruby
Rails
ActiveRecord
callback
activesupport

Active Supportのskip_callback動かない問題

1. 問題

TemporaryArticleArticleからextendしています。
Articleモデル内に、push_notificationコールバックを定義しています。

models/article.rb
class Article
  include Mongoid::Document

  field: name

  has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle
  after_save :push_notification

  def push_notification
    #do great work
  end

end
models/temporary_article.rb
class TemporaryArticle < Article
  belongs_to :public_article, class_name: Article.name
  skip_callback :save, :after, :push_notification
end

TemporaryArticlepush_notificationを呼びたくないので、skip_callbackを利用して、push_notificationを呼ばないようにします。
しかし、skip_callbackがうまく動かなくて、TemporaryArticle#saveするとき、 push_notificationはまだ呼ばれています。

2. TL;DR

解決方法: TemporaryArticle定数がある行は各コールバックメソッドの下に置いてください。

models/article.rb
class Article
  include Mongoid::Document

  field: name

  #move this line to the bellow of callback methods
  #has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle

  after_save :push_notification

  # move to here
  has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle

  def push_notification
    #do great work
  end

end

3. 詳しく説明

3.1. callback- How it work?

callbackのソースコードを見てみましょう。

rails/activesupport/lib/active_support/callbacks.rb
def set_callback(name, *filter_list, &block)
  type, filters, options = normalize_callback_params(filter_list, block)

  self_chain = get_callbacks name
  mapped = filters.map do |filter|
    Callback.build(self_chain, filter, type, options)
  end

  __update_callbacks(name) do |target, chain|
    options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped)
    target.set_callbacks name, chain
  end
end

def skip_callback(name, *filter_list, &block)
  type, filters, options = normalize_callback_params(filter_list, block)

  options[:raise] = true unless options.key?(:raise)

  __update_callbacks(name) do |target, chain|
    filters.each do |filter|
      callback = chain.find { |c| c.matches?(type, filter) }

      if !callback && options[:raise]
        raise ArgumentError, "#{type.to_s.capitalize} #{name} callback #{filter.inspect} has not been defined"
      end

      if callback && (options.key?(:if) || options.key?(:unless))
        new_callback = callback.merge_conditional_options(chain, if_option: options[:if], unless_option: options[:unless])
        chain.insert(chain.index(callback), new_callback)
      end

      chain.delete(callback)
    end
    target.set_callbacks name, chain
  end
end

def define_callbacks(*names)
  options = names.extract_options!

  names.each do |name|
    name = name.to_sym

    set_callbacks name, CallbackChain.new(name, options)

    module_eval <<-RUBY, __FILE__, __LINE__ + 1
      # do great work
    RUBY
  end
end

各コールバックメソッドは一つのCallBack配列を利用して、管理しています。
set_callbackするとき、各コールバックメソッドをCallBack配列に追加します。
skip_callbackするとき、skipするコールバックメソッドをCallBack配列から除きます。

  • メリット

一つの配列を利用するので、管理しやすいです。

  • デメリット

skip_callbackを呼ぶタイミングに気をつける必要があります。
なぜなら、set_callbackを呼ぶ前に、skip_callbackを呼んでしまうと、skipしたいコールバックメソッドはCallBack配列内にまだ追加されていないため、skipできないです。

3.2. 上で取り上げた問題のケース

おそらく、skip_callbackを呼ぶとき、CallBackpush_notificationが追加されてないので、動かなかった。
set_callbackskip_callbackが呼ばれる順番を確認しましょう。

models/article.rb
class Article
  has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle
  after_save :push_notification
end
models/temporary_article.rb
class TemporaryArticle < Article
  belongs_to :public_article, class_name: Article.name
  skip_callback :save, :after, :push_notification
end

autoload_pathsにより、ファイルロードの流れは
1. models/temporary_article.rbを読み込む
2. temporary_article.rbの行1を読む
  - TemporaryArticle定数を見つけるが、この前、classキーワードがありますので、定数ではないと判断されます。autoloadingがトリガーされなかった。
  - 次、Article定数を見つけます。これは定数なので、autoloadingがトリガーされます=>models/article.rbをロードします。
3. models/article.rbの行2を読む
  - TemporaryArticle定数を見つけます、これは定数なので、autoloadingがトリガーされます =>models/temporary_article.rbをロードします。
4. temporary_article.rbは引き続き読む
  -行2Article定数を見つけるが、前に見つけましたので、autoloadingがトリガーされない
  -行3 skip_callbackを呼ぶ
5. article.rbは引き続き読む
  - after_saveコールバックを呼ぶ

結局、skip_callbackを呼んでから、after_saveコールバックを呼ばれます。
skip_callbackが効かなかった。

解決策

案1: implicit(暗黙定義)
class_name: TemporaryArticleを使わずに直接参照します。


 #has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle
 has_one :temporary_article, dependent: :destroy

案2: できれば、各コールバックは上に移動する

models/article.rb
class Article
  #move this line to the bellow of callback methods
  #has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle

  after_save :push_notification

  # move to here
  has_one :draft_article, dependent: :destroy, class_name: TemporaryArticle

end

案3:CallBackのアルゴリズムを変える
今現在、一つのCallBack配列を利用して、管理していますが、デメリットがあります。
もし、callbackskip_callbackメソッドを分けて、別々配列で管理すれば、問題解決できるかなと思います。

4.参考

http://guides.rubyonrails.org/autoloading_and_reloading_constants.html