Ruby on Rails Tips

親モデルの永続化していない情報を子モデルで使うには

Ruby on Railsでは、has_many関連を使って親子構造を表すことがよくあります。一般的なオブジェクト指向の親子構造であれば、次のように、親オブジェクトにセットした情報を子オブジェクトから参照できるように実装することは難しくはありません。

例)親の属性accessed_byに値(user)をセットしたら、子でも利用できる

  parent.accessed_by = user
  ...
  parent.children each do |child|
    p child.accessed_by
    # => user が取得できる
  end

しかし、ActiveRecordの親子の場合で、上記の例のaccessed_byがデータベースのカラムではなく、処理の流れの中で設定される永続化されないデータである場合は、これを実現するのは簡単ではありません。単純な方法としては、次のようなものが考えられます。

単純な方法. 親に情報をセットしたときに子にセットする

  class Parent << ActiveRecord::Base
    has_many :children

    attr_reader :accessed_by

    def accessed_by=(accessed_by)
      @accessed_by = accessed_by
      children.each{|c| c.accessed_by = accessed_by}
    end
  end

  class Child << ActiveRecord::Base
    belongs_to :parent

    attr_accessor :accessed_by
  end

この方法は、親にデータをセットし、その後、内部的にキャッシュされたchildrenを参照するという場合にはうまくいきます。難点としては、childrenの取得しなおしや、関連のfindなどを使った検索には対応できないという点があります。

childrenを取り直したり、関連のfindをして得た子には、親のaccessed_by値が入っていない

  parent.accessed_by = user
  children = parent.children
  # このとき得られるchildrenにはaccessed_by値が入っている

  children = parent.children(true)
  # 取り直すと入っていない

  child = parent.children.find(13)
  # 特定の子を検索で得ると、accessed_by値は入らない

また、気持ちとしては、親の情報は親にだけ持たせて、子からは次のように親の情報を参照したいところですが、ActiveRecordを普通に使うと、parentの子であるchildの親のparentは、オブジェクトとしては別物になってしまうため、それもできません。

  parent.accessed_by = user
  p parent.children.first.accessed_by # 内部で Child#accessed_byを呼ぶが・・・

  class Child < ActiveRecord::Base
    belongs_to :parent

    def accessed_by
      parent.accessed_by
      # このとき得られるparentは、parent.childrenとしたときのparentとは別物
    end
  end

ActiveRecordはこのような処理が苦手といえます。しかし、モデルはビジネスロジックの中心であり、オブジェクト指向的にきれいにロジックを書こうとすると、どうしてもこの問題で困る時があります。いまのところ、一番良いのではないかと思う解法は、次のようなものです。

まず、has_many関連の拡張を使って、findの動作を変更します。このとき、proxy_ownerを使って親オブジェクトにアクセスできるので、accessed_byをコピーすることができます。

  class Parent < ActiveRecord::Base
    has_many :children do
      def find(*args)
        result = super
        if result.kind_of?(Array)
          result.each{|r| r.accessed_by = proxy_owner.accessed_by}
        elsif result
          result.accessed_by = proxy_owner.accessed_by
        end
        result
      end
    end
  end
これで、以下のような手順で、childrenからaccessed_byが正しく得られることになります。
  parent.accessed_by = user
  parent.children.each do |c|
    p c.accessed_by # 正しく入っている
  end
  child = parent.children.find(13)
  p child.accessed_by # 正しく入っている

しかし、後からparentのaccessed_byを変更しても、子に反映されないのが問題です。そこで、次のように親のaccessed_by設定メソッドを書き換えます。

  class Parent < ActiveRecord::Base
    has_many :children do
      def find(*args)
        result = super
        if result.kind_of?(Array)
          result.each{|r| r.accessed_by = proxy_owner.accessed_by}
        elsif result
          result.accessed_by = proxy_owner.accessed_by
        end
        result
      end
    end
    attr_reader :accessed_by
    
    def accessed_by=(accessed_by)
      @accessed_by = accessed_by
      children.each{|c| c.accessed_by = accessed_by}
    end

  end

これで概ね満足な動作になりますが、ひとつ不満なのは、親にaccessed_byを指定すると必ずchildrenが用意される(childrenテーブルが検索される)点です。children.find(13)やchildren.sizeなどだけが必要な場合、これは無駄な処理となってしまいます。そこで、childrenをまだキャッシュしていなければ、childrenをロードしないようにする処理を加えることが考えられます。キャッシュ状態を手軽に調べるために、関連を拡張して、proxy_targetの状態を返すだけのcached?メソッドを作ります。これをみて、親へのデータセットの際に子のデータを書き換えるかどうかを判断します。

 class Parent < ActiveRecord::Base
   has_many :children do

     def find(*args)
       result = super
       if result.kind_of?(Array)
         result.each{|r| r.accessed_by = proxy_owner.accessed_by}
       elsif result
         result.accessed_by = proxy_owner.accessed_by
       end
       result
     end

     def cached?
        !proxy_target.empty?
     end
   end

    def accessed_by=(accessed_by)
      @accessed_by = accessed_by
      children.each{|c| c.accessed_by = accessed_by} if children.cached?
    end
   
 end

これで、childrenのロードが必要ないときには行わないようになり、さらに改善されます。