ActiveRecord オブジェクトをコピーする


dupを使ってコピーする

ここ最近「Railsで○○をskip」という記事が続いていましたが、どういう時にskipが必要になったかと言うと、ActiveRecord オブジェクトをコピーする際に必要になりました。

こちらの記事 を参考にさせていただきActiveRecord オブジェクトをコピーしようと思ったのですが、Rails3.1以降でdupとcloneの動作が逆になったようです。

Rails3.1のRelease Notes(意訳)には

・ActiveRecord::Base#dup and ActiveRecord::Base#clone semantics have changed to closer match normal Ruby dup and clone semantics.

ActiveRecord::BaseのdupとActiveRecord::BaseのcloneはRubyのdupとcloneの動作により近づくように変更になりました。

・Calling ActiveRecord::Base#clone will result in a shallow copy of the record, including copying the frozen state. No callbacks will be called.

ActiveRecord::Baseのcloneは凍結状態を含む、レコードのshallow(浅い)コピーを行います。コールバックは呼ばれません。

・Calling ActiveRecord::Base#dup will duplicate the record, including calling after initialize hooks. Frozen state will not be copied, and all associations will be cleared. A duped record will return true for new_record?, have a nil id field, and is saveable.

ActiveRecord::Baseのdupは初期化フック後の呼び出しを含むレコードを複製します。凍結状態はコピーされず、すべての関連はクリアされます。複製されたレコードはnew_record?でtrueを返し、idフィールドはnilで、保存可能になります。

とあり、dupを使えばレコードの複製ができるようになったとあります。

参考にさせていただいた記事のコードをRails3.1以降で使う場合は以下のようになります。

# Rails >= 3.1
user = User.find(1)
p user.id # 1
p user.user_name # test

dup_user = user.dup
clone_user = user.clone

# dupしたオブジェクトはIDが含まれない
p dup_user.id # nil
p clone_user.id # 1

# dup したオブジェクトは new_record? == true
p user.new_record? # false
p dup_user.new_record? # true
p clone_user.new_record? # false

# clone で複製したオブジェクトは複製元オブジェクトの属性値を変更すると値が変わる
user.user_name = 'new name'
p dup_user.user_name # test
p clone_user.user_name # new name

save時に色々skipする

dupを使ってデータのコピーをする際にはまったのがbefore_save等のcallbackでした。

callbackが設定されている場合、単純にdupをしてsaveをするとcallbackが実行されるため、想定していたものと異なるデータが保存されていました。

以下のように画像のアップロード機能を持つDataモデルがあったとします。

Dataモデルはbefore_saveでトークンの生成を行っています。

このDataモデルにはバージョン番号があり、既存バージョンから新バージョンを作成できる Data.copy 機能を作ってみます。

# Schema
create_table :master_data do |t|
  t.string   "name"
  t.integer  "version", :default => 0  
  t.string   "img"
  t.string   "token"
  t.timestamps
end


# Model
class Data < ActiveRecord::Base
  # 画像のアップロード
  mount_uploader :img, ImgUploader

  before_save :generate_token
  
  def generate_token
    # トークン生成処理
  end
  
  # base_versionのDataをtarget_versionとしてコピーする
  def self.copy(base_version, target_version)
    # base_versionに該当するDataをすべて取得
    self.where(version: base_version).each do |data|
      dup_data = data.dup # Dataをコピー
      dup_data.version = target_version # target_versionとして設定
      dup_data.save
    end
  end
end

上記のコードだとsave時にcallbackが実行されるため、generate_tokenで異なるトークンが生成されてしまいます。

また、img画像も画像名をハッシュ化したりしている場合にはコピー前と異なる画像名で保存されてしまいます。

そして、updated_atもsave時の値が保存されてしまいます。

これらを回避するために、これまでの記事で紹介したskip機能を使います。

するとcopyメソッドは以下のようになります。

# Model
class Data < ActiveRecord::Base
  # 画像のアップロード
  mount_uploader :img, ImgUploader

  before_save :generate_token
  
  def generate_token
    # トークン生成処理
  end
  
  # base_versionのDataをtarget_versionとしてコピーする
  def self.copy(base_version, target_version)
    # generate_tokenをskip
    self.skip_callback(:save, :before, :generate_token)
    
    # carrierwaveのcallbackをskip
    self.skip_callback(:save, :after, :store_img!)
    self.skip_callback(:save, :before, :write_img_identifier)
    self.skip_callback(:commit, :after, :remove_img!)
    self.skip_callback(:update, :before, :store_previous_model_for_img)
    self.skip_callback(:save, :after, :remove_previously_stored_img)
    
    # updated_atの自動更新を無効化
    self.record_timestamps = false
    
    # base_versionに該当するDataをすべて取得
    self.where(version: base_version).each do |data|
      dup_data = data.dup # Dataをコピー
      dup_data.version = target_version # target_versionとして設定
      dup_data.save
    end
    
    # generate_tokenを有効化
    self.set_callback(:save, :before, :generate_token)
    
    # carrierwaveのcallbackを有効化
    self.set_callback(:save, :after, :store_img!)
    self.set_callback(:save, :before, :write_img_identifier)
    self.set_callback(:commit, :after, :remove_img!)
    self.set_callback(:update, :before, :store_previous_model_for_img)
    self.set_callback(:save, :after, :remove_previously_stored_img)
    
    # updated_atの自動更新を有効化
    self.record_timestamps = true
  end
end

これでdupしたデータをそのままコピーすることができます。

メソッドがかなり長くなってしまっているのでなんとかしたいところ。。。

参考