LoginSignup
3
1

More than 3 years have passed since last update.

【Rails】counter_cultureの処理を追ってみた

Last updated at Posted at 2021-02-19

なにこれ

筆者が業務でrailsのcounter_cultureというgemを使用する機会がありました。
そこで機能が正しく動作するかを確認するために、gemの処理内容を理解しようとソースコードを読みました。
それらをまとめた備忘録です!

お願いしたいこと

筆者はこういった処理を追ってみる解説が初めてです。
記事を読みやすくするために、不要なオプションなどの記述を削除して、必要な機能の解説に留めているつもりです!

また、間違っている点などがありましたら、ご指摘いただけますと幸いです。
それでは、よろしくお願いいたします。

前提条件

この記事は↓の3つを理解しているものとして進めます。(継承、proc、アソシエーションなど)
- Rubyのチェリー本
- Railsチュートリアル
- その他、オブジェクト指向に関する知識

また、今回説明するcounter_cultureのバージョンは2.7です。(2021/02/19現在)

そもそもcounter_cultureってなに

Ruby on Railsには、counter_cacheという親レコードが持ってる子レコードの数を測定してカラムに吐き出してくれる便利な機能があります。

参考

Railsガイド counter_cache
https://railsguides.jp/association_basics.html#belongs-to%E3%81%AE%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3-counter-cache

ただし、counter_cacheは↓の2つの仕様なので、使い勝手が良いとは言えませんでした。
- counter_cacheは子レコードの数しか図ることが出来ない
- 途中からcounter_cacheを追加しても、既存のデータの数は集計できない

それらを解決するためにcounter_cacheの拡張版として、
counter_cultureというgemがあります。

counter_cultureができること

  • 条件に一致した子レコード数を集計
  • 途中からカウンターを追加して、現在の値を集計

その他の細かな内容はGithubを参照ください。
https://github.com/magnusvk/counter_culture

それでは本番へ↓

counter_cultureの処理

ディレクトリ構造

counter_cultureは基本的に3つのファイルで構成されています。
- lib/counter_culture/extentions.rb
- lib/counter_culture/counter.rb
- lib/counter_culture/reconciler.rb

extentions.rbは全体的な管理
counter.rbはカウントの条件の処理
reconciler.rbは再集計の処理

今回はextentions.rbcounter.rbを中心に見ていきます。
reconciler.rbまでは追う力がなかった

extentions.rb

まずはextentions.rbを見て大枠を掴みます。
一部省略しています。

extentions.rb
module CounterCulture
  module Extensions
    extend ActiveSupport::Concern

    module ClassMethods
      # this holds all configuration data
      def after_commit_counter_cache
        config = @after_commit_counter_cache || []
        if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
          config = superclass.after_commit_counter_cache + config
        end
        config
      end

      # called to configure counter caches
      def counter_culture(relation, options = {})
        unless @after_commit_counter_cache
          # initialize callbacks only once
          after_create :_update_counts_after_create

          before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?

          after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?

          # we keep a list of all counter caches we must maintain
          @after_commit_counter_cache = []
        end

        if options[:column_names] && !options[:column_names].is_a?(Hash)
          raise ":column_names must be a Hash of conditions and column names"
        end

        # add the counter to our collection
        @after_commit_counter_cache << Counter.new(self, relation, options)
      end

クラスメソッドとして、after_commit_counter_cachecounter_culture
というのを定義しているのが分かりました。

先にcounter_cultureメソッドを見ます。

extentions.rb
      def counter_culture(relation, options = {})
        unless @after_commit_counter_cache
          # initialize callbacks only once
          after_create :_update_counts_after_create

          before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?

          after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?

          # we keep a list of all counter caches we must maintain
          @after_commit_counter_cache = []
        end

        # add the counter to our collection
        @after_commit_counter_cache << Counter.new(self, relation, options)
      end

@after_commit_counter_cacheというインスタンス変数に値がなかったら、
after_createやらbefore_destroyなど、アクション時にコールバックでプライベートメソッドを実行してそうです。
メソッド名から見て、こいつらがカウンターの処理をやってそうだと判断します。

extentions.rb
        @after_commit_counter_cache << Counter.new(self, relation, options)

下から2行目でCounterクラスのインスタンスで作成したものを、
@after_commit_counter_cacheに突っ込んでるのが分かりました。
この処理はinitializeみたいな感じなんですかね?正直このあたりが完全に理解できてないです。。。

一応、counter.rbを作成した時のinitializeの中身を見ておきます。

counter.rb
    CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude]
    ACTIVE_RECORD_VERSION = Gem.loaded_specs["activerecord"].version

    attr_reader :model, :relation, *CONFIG_OPTIONS


    def initialize(model, relation, options)
      @model = model
      @relation = relation.is_a?(Enumerable) ? relation : [relation]

      if options.fetch(:execute_after_commit, false)
        fail("execute_after_commit was removed; updates now run within the transaction")
      end

      @counter_cache_name = options.fetch(:column_name, "#{model.name.demodulize.tableize}_count")
      @column_names = options[:column_names]
      @delta_column = options[:delta_column]
      @foreign_key_values = options[:foreign_key_values]
      @touch = options.fetch(:touch, false)
      @delta_magnitude = options[:delta_magnitude] || 1
      @with_papertrail = options.fetch(:with_papertrail, false)
    end

なんかいっぱいインスタンス変数を定義してる。笑
githubのドキュメントを見た感じだと、使うオプションとかを定義してるっぽい。

extentions.rbの戻って、一番上の_update_counts_after_createメソッドから見てみようと思います!

    # called by after_create callback
    def _update_counts_after_create
      self.class.after_commit_counter_cache.each do |counter|
        # increment counter cache
        counter.change_counter_cache(self, :increment => true)
      end
    end

selfのクラスメソッドのafter_commit_counter_cacheを実行して、
その結果をeach文で回して、カウント用のchange_counter_cacheメソッドを実行してるって感じです。
今はまだ解説しませんが、このあたりの引数はのちのち重要になってきます!

では、after_commit_counter_cacheメソッドの中身を見てみます。

      def after_commit_counter_cache
        config = @after_commit_counter_cache || []
        if superclass.respond_to?(:after_commit_counter_cache) && superclass.after_commit_counter_cache
          config = superclass.after_commit_counter_cache + config
        end
        config
      end

変数configを定義して、after_commit_counter_cacheがあれば処理を実行する。ってぽいですねー。
configに色々設定する値を突っ込んでるのかな?
そして返り値としてconfigを定義していました。

次にカウント処理を行っているcounter.change_counter_cache(self, :increment => true)というメソッド読んでみます。
(読みやすくするために、今回の解説に関係ない一部のオプションの記述を削除しています。)

counter.rb

counter.rb
    def change_counter_cache(obj, options)
      change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

      # default to the current foreign key value
      id_to_change = foreign_key_value(obj, relation, options[:was])

      if id_to_change && change_counter_column
        delta_magnitude = if delta_column
                            (options[:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0
                          else
                            counter_delta_magnitude_for(obj)
                          end
        # increment or decrement?
        operator = options[:increment] ? '+' : '-'

        klass = relation_klass(relation, source: obj, was: options[:was])

        # MySQL throws an ambiguous column error if any joins are present and we don't include the
        # table name. We isolate this change to MySQL because sqlite has the opposite behavior and
        # throws an exception if the table name is present after UPDATE.
        quoted_column = if klass.connection.adapter_name == 'Mysql2'
                          "#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
                        else
                          "#{model.connection.quote_column_name(change_counter_column)}"
                        end

        # we don't use Rails' update_counters because we support changing the timestamp
        updates = []
        # this updates the actual counter
        updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"

        primary_key = relation_primary_key(relation, source: obj, was: options[:was])

        klass.where(primary_key => id_to_change).update_all updates.join(', ')
      end
    end

すごいいっぱい書いてある(語彙力)
とりあえず一行ずつ潰していきます!

counter.rb
    def change_counter_cache(obj, options)
      change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

change_counter_cacheの引数として渡っているのが、objとoptionsでした。

extentions.rb
        counter.change_counter_cache(self, :increment => true)

objはcounter自身、optionsは:increment => trueなどのオプションですね。
ちなみに、counter自身とは、counter_cultureを記述したモデルのことになります。
↓の例だと、Productクラスののインスタンスになります。

class Product < ActiveRecord::Base
  belongs_to :category
  counter_culture :category
end

変数change_counter_columnに突っ込んでる値を確認します。

counter.rb
change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

今読んでるのが_update_counts_after_createメソッドで、それにoptions[:counter_column]はないので、counter_cache_name_forが呼び出されることになります。
引数は(obj)なので、クラスのインスタンスになります。

counter.rb
    # Gets the name of the counter cache for a specific object
    #
    # obj: object to calculate the counter cache name for
    # cache_name_finder: object used to calculate the cache name
    def counter_cache_name_for(obj)
      # figure out what the column name is
      if counter_cache_name.is_a?(Proc)
        # dynamic column name -- call the Proc
        counter_cache_name.call(obj)
      else
        # static column name
        counter_cache_name
      end
    end

counter_cache_name.is_a?(Proc)とあります。
今回はelseなので、インスタンス自身のcounter_cache_nameつまりproduct_countが返されるようになります。
(counter_cultureはデフォルトでモデル名 + _countを見るため。procを渡す方法は後述)

返り値が分かったので、戻って次に進みます。

counter.rb
    def change_counter_cache(obj, options)
      change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

      # default to the current foreign key value
      id_to_change = foreign_key_value(obj, relation, options[:was])

変数id_to_changeforeign_key_value(obj, relation, options[:was])というメソッドの返り値を代入してるので、これを見ます。
変数relationには、端的に言うとリレーション先が格納されています。(initialize時にattr_readerで定義されてる)
モデルにcounter_culture :categoryと書いた場合は、[:category]が入ります。
(なぜアソシエーション先が単体なのに配列なのかは調査中)

counter.rb
    def foreign_key_value(obj, relation, was = false);
      relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
      first_relation = relation.first
      if was
        first = relation.shift
        foreign_key_value = attribute_was(obj, relation_foreign_key(first))
        klass = relation_klass(first, source: obj, was: was)
        if foreign_key_value
          value = klass.where(
                    "#{klass.table_name}.#{relation_primary_key(first, source: obj, was: was)} = ?", foreign_key_value
                  ).first
        end
      else
        value = obj
      end
      while !value.nil? && relation.size > 0
        value = value.send(relation.shift)
      end
      return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))
    end

先に結果を言うと、このメソッドの返り値はカウンターのカラムを追加してる親レコードのIDの数値を返します。(10など)
なぜ10が返されるのかを考えたほうが分かりやすいと思います!

relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
first_relation = relation.first
if was
  # ~~今は省略~~
else
  value = obj
end

変数relationを再定義して、relationを複製しています。
relation.firstでrelationを取り出しています。
今の所はrelation.firstには:relationが格納されています。

(余談:毎回配列で変えるのにis_a?(Enumerable)してるのは何故か?次の行でfirstで配列から取得してるから、Enumerableなのは確定してるのに。謎みが深い。)

ifでoptionsに[:was]があればtrueを返しますが、今回はないのでelseになります。
変数valueにobj自身を代入していますね。

      while !value.nil? && relation.size > 0
        value = value.send(relation.shift)
      end
      return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))

valueがあってrelation配列が0以上ならwhile文が走ります。
valueにrelation.firstをsendする。
つまりproduct.categoryという状態になります。

最後のreturnで、valueを良い感じに処理してます。

relation_primary_keyメソッドに引数で:category, objを渡してますね!(wasはfalse)

    def relation_primary_key(relation, source: nil, was: false)
      reflect = relation_reflect(relation)
      klass = nil
      reflect.association_primary_key(klass)
    end

relation_reflectメソッドはおそらくrelationの情報を返しているんだと思います。
ざっと確認します!

    def relation_reflect(relation)
      relation = relation.is_a?(Enumerable) ? relation.dup : [relation]

      # go from one relation to the next until we hit the last reflect object
      klass = model
      while relation.size > 0
        cur_relation = relation.shift
        reflect = klass.reflect_on_association(cur_relation)
        raise "No relation #{cur_relation} on #{klass.name}" if reflect.nil?

        if relation.size > 0
          # not necessary to do this at the last link because we won't use
          # klass again. not calling this avoids the following causing an
          # exception in the now-supported one-level polymorphic counter cache
          klass = reflect.klass
        end
      end

      return reflect
    end

またrelationを複製してますね。
klassにmodel(obj自身のProductクラス)を代入してますね。

relationのsizeが0になるまでwhileします
ちなみにklassっていうのは、classって書くと予約語に引っかかるので、それの対策のメタプロっていうらしいです(昨日知った)

cur_relationに先頭のrelation(:category)を突っ込んでますね。
reflect = klass.reflect_on_association(cur_relation)で、product.categoryを再現してreflectに代入してます。
klass = reflect.klassでリフレクトしたクラスをクラスに突っ込んでますね。
これは最後のリレーションのクラスを突っ込むってことだと思います。

whileが終わったらreflect(今回はcategory)を返してます。

かなり冒険しましたが、relation_primary_keyに戻ります。

    def relation_primary_key(relation, source: nil, was: false)
      reflect = relation_reflect(relation)
      klass = nil
      reflect.association_primary_key(klass)
    end

reflectにはcategoryの親レコード(インスタンス)が入ってます。
それに対してassociation_primary_keyを返してます(今回の場合はID)
途中でklassにnilを代入して引数に渡してる理由までは追ってないです。。。

やっと本来のforeign_key_valueメソッドに返ってきました。

    def foreign_key_value(obj, relation, was = false);
      # ~~省略~~
      return value.try(relation_primary_key(first_relation, source: obj, was: was).try(:to_sym))

valueでtryしたのはIDでした。それをto_symでシンボルにして、value(:id)という形にして、親レコード(変更対象のID)を取得してますね。
かなり長くなりましたが、これがIDの数値(10)などを返す仕組みです。

思ったより長くなってきたので、さくさく進めます。笑

    def change_counter_cache(obj, options)
      change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

      id_to_change = foreign_key_value(obj, relation, options[:was])

      if id_to_change && change_counter_column
        # 〜〜処理〜〜
      end

change_counter_columnにはproduct_count、id_to_changeには10という数値が入っている状態です。
ifでこれらの値が存在するならtrueを返すようにしています。
次の処理を見ていきます。

        delta_magnitude = if delta_column
                            (options[:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0
                          else
                            counter_delta_magnitude_for(obj)
                          end

        operator = options[:increment] ? '+' : '-'

        klass = relation_klass(relation, source: obj, was: options[:was])

        # MySQL throws an ambiguous column error if any joins are present and we don't include the
        # table name. We isolate this change to MySQL because sqlite has the opposite behavior and
        # throws an exception if the table name is present after UPDATE.
        quoted_column = if klass.connection.adapter_name == 'Mysql2'
                          "#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
                        else
                          "#{model.connection.quote_column_name(change_counter_column)}"
                        end

変数delta_magnitudeには、結論だけ言うとオプションを指定しない限り「1」が代入されると考えてください。

optionsにincrementがあれば、+を返す。なければ-を返すって処理があります。
これがレコードの作成、更新、削除のカウントに対応できる仕組みです。

klass = relation_klass(relation, source: obj, was: options[:was])でrelation先のクラス(親レコード)を返してます。今回の場合はcategoryになります。

if klass.connection.adapter_name == 'Mysql2'だったら変数quoted_columnに↓の値を返します

"#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"

↑の内容は、railsのメソッドが多様されてるので省略しますが、categorys.product_count"のような値が代入されます。
これが実行されるSQL文です。

        updates = []
        # this updates the actual counter
        updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"

updatesっていう配列を定義して、そこに実行するSQL文を突っ込んでいます。
今回は↓のようなSQLが実行されることになります。

UPDATE `category` SET `category`.`product_count` = COALESCE(`category`.`product_count`, 0) + 1 WHERE `category`.`id` = 10

続きを見ていきます!

    def change_counter_cache(obj, options)
    # ~~省略~~
        primary_key = relation_primary_key(relation, source: obj, was: options[:was])

        klass.where(primary_key => id_to_change).update_all updates.join(', ')
      end

変数primary_keyには、relation_primary_keyの返り値(IDなどの主キーの値)が入ります。
先程解説したメソッドと同じのを使いまわしてます!

そして、ようやく最後にレコードを特定した後に、update_allでSQLが実行されて、カウントされることになります。

destroy時の処理

今まではcreate時のアクションを解説しました。
次はdestroy時のアクションを見ていきます。

extentions.rb
    private

    # called by after_destroy callback
    def _update_counts_after_destroy
      self.class.after_commit_counter_cache.each do |counter|
        # decrement counter cache
        counter.change_counter_cache(self, :increment => false)
      end
    end

createと一番の違いは、change_counter_cacheメソッドの引数が:increment => falseになってることです。
これは、レコードが削除される➡️カウントが減る➡️引き算を行う。っていう考えになります。
初めて知った時に「ほー」って思いました。笑

update時の処理

ここが最難関だと思いますが、逃げずに立ち向かいます。笑

extentions.rb
    # called by after_update callback
    def _update_counts_after_update
      self.class.after_commit_counter_cache.each do |counter|
        counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
        counter_cache_name = counter.counter_cache_name_for(self)

        if counter.first_level_relation_changed?(self) ||
            (counter.delta_column && counter.attribute_changed?(self, counter.delta_column)) ||
            counter_cache_name != counter_cache_name_was
          # increment the counter cache of the new value
          counter.change_counter_cache(self, :increment => true, :counter_column => counter_cache_name)
          # decrement the counter cache of the old value
          counter.change_counter_cache(self, :increment => false, :was => true, :counter_column => counter_cache_name_was)
        end
      end
    end

ifが置かれていますが、現状だと確実にこのifはtrueになるので割愛します
counter_cache_name != counter_cache_name_wasは確実にtrueになるハズ

updateの一番の特徴は、変更前と変更後を比較していることです。
比較しているのは、counter_cache_name_wascounter_cache_nameです。(メソッド名通り!)

counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
        counter_cache_name = counter.counter_cache_name_for(self)

counter_cache_name_forメソッドは先程解説しました。
カウンター(self)のカウンター対象のカラムを文字列で返すメソッドです。

変数counter_cache_name_wasを定義してる時は、counter.previous_model(self)の返り値を突っ込んでますが、これが何の値を返してるか確認します。

counter.rb
    def previous_model(obj)
      prev = obj.dup
      changes_method = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? :saved_changes : :changed_attributes
      obj.public_send(changes_method).each do |key, value|
        old_value = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? value.first : value
        prev[key] = old_value
      end

      prev
    end

まずobjをdupしてprevに代入してます。最後にprevをreturnしてるので、このprevに何か処理を加えることが予想されます。

次に変数changes_methodを定義していますが、これの条件はbinding.pryだと分からずで、結論:saved_changesというシンボルを代入しています。
どうやら:changed_attributesはRailsのバージョン5.1以前で使用されてたメソッドで、現在はattributes_in_databaseというメソッドに置き換えられてるそうです。

obj.public_send(changes_method).each do |key, value|で、
実行してる内容としてはobj(:saved_changes)になります。
それをハッシュで取得されるので、key valueでeachを回すっていう感じです。

変数old_valueには、条件でtrueを返します。
で、value.firstとありますが、valueの中身には↓のような値が格納されています。

{ updated_at"=>[Thu, 18 Feb 2021 13:36:00 JST +09:00, Thu, 18 Feb 2021 13:38:48 JST +09:00] }

配列の0番目に変更前、配列の1番目に変更後の値が確認できます。
今回は変更前の値を取得したいので、value.firstを実行してる。というわけです。

変更前のカラムを再現するために、eachで回してるprev[key]にvalueを突っ込みます。
それを繰り返した後にreturnする。という感じです!

なぜこんな処理をしているのか

筆者も最初は理解できませんでした。
結論を先にいうと、変更前と変更後の差分を検知するためです。

例として下記のようなcounter_cultureを設定したとします。

post.rb
  counter_culture :user,
                  column_name: proc { |model| model.is_special == true ? 'sp_post_count' : nil },
# 今回は解説しませんが、column_namesはcounter_culture_fix_countsというメソッドを実行するために必要な記述です。
                  column_names: {
                      Post.is_special => :sp_post_count,
                      Post.not_is_special => nil
                  }
  scope :is_special, ->{ where(is_special: true) }
  scope :not_is_special, ->{ where(is_special: false) }

簡単に説明すると、
userは複数のpostを持っています。
postにはis_specialというboolean型のカラムがあります。
一人のユーザーがis_specialなpostを何個持ってるかカウントするために、userにsp_post_countというカラムがあります。

実際の例で何が起こるかを説明します。

まずはpostのupdateを行います。
内容は、is_specialをfalseからtrueに変更する。というものです。

counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
counter_cache_name = counter.counter_cache_name_for(self)

上記のコードを実行した時に、counter_cache_name_wasには変更前の値つまりis_special == falseのものが取得できます。
counter_cache_nameには更新後の値つまりis_special == trueのものが格納されます。

上記の条件でcounter_cache_name_forメソッドを呼び出すと返り値は、
変更前の値はnil、変更後はsp_post_countが入ります。

念のためにcounter_cache_name_forメソッドの処理を確認します。

counter.rb
    def counter_cache_name_for(obj)
      if counter_cache_name.is_a?(Proc)
        counter_cache_name.call(obj)
      else
        counter_cache_name
      end
    end

上記のsp_post_countを持った例だと、column_nameはprocなので、callが実行されることになります。
procを使う理由は、カウントする時に条件を加えるためです。

post.rb
counter_culture :user,
                  column_name: proc { |model| model.is_special == true ? 'sp_post_count' : nil },

counter.counter_cache_name_for(counter.previous_model(self))を行う時は変更前の値で確認することになります。
今回の場合だとfalseです。falseでprocを実行すると、nilが返されることになります。
つまり、counter_cache_name_wasにはnilが入ります。

counter_cache_nameには更新後の値が入る(true)ので、procでtrueのsp_post_countという文字列を返すことになります。

extentions.rb
          counter.change_counter_cache(self, :increment => false, :was => true, :counter_column => counter_cache_name_was)
          counter.change_counter_cache(self, :increment => true, :counter_column => counter_cache_name)

上記の変数が確定した状態で、change_counter_cacheメソッドを呼び出しましょう。
この時に引数に注目してください。

counter_cache_nameを引数に渡した時は:increment => trueになっています。
反対にcounter_cache_name_wasを渡したときには:increment => falseになっています。

これの理由は、今更新したものがカラムを持っている(nilじゃない)なら、新しくカウントする対象が増えるからたし算をする。
逆に変更前のものがカラムを持っていたら、「カウントの対象外になった」ということを表します。

はい、難しいですね。

change_counter_cacheメソッドを確認します。

counter.rb
    def change_counter_cache(obj, options)
      change_counter_column = options.fetch(:counter_column) { counter_cache_name_for(obj) }

      id_to_change = foreign_key_value(obj, relation, options[:was])

      if id_to_change && change_counter_column
        # ~~省略~~
      end
    end

if id_to_change && change_counter_columnというコードがありますが、ここでchange_counter_columnがnilだった場合は条件外となって処理が終了することになります。

なので、先程のコードで問題なく動作する。ということになります。
差分を見てるということをイメージすると分かりやすいかと思います!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1