Rails Webook

Web業界で働いています。Railsを中心にプログラミングや事業開発のようなことを書いていきます。

RailsのActiveRecordのAutosaveAssociationのコードリーディング

RailsのActiveRecordのautosave機能についてソースコードリーディングをしました。

Autosaveは、親モデルが保存された時に、関連するモデルも一緒に作成/更新する機能を提供します。 また、関連するモデルでmark_for_destricutionメソッドを実行し、削除フラグを立てることで、親モデルを保存時に関連レコードを削除することもできます。
これらのレコードの操作はトランザクション内で実行されるのでDBでデータ不整合が発生しません。

1. Autosave機能のサンプルコード

サンプルコードを見るとAutosave機能の挙動がわかると思います。

# アソシエーションの`autosave: true`というオプションを追加することでAutosave機能を利用できます。
class User < ActiveRecord::Base
  has_one :setting, autosave: true
end

user = User.find(1)
user.name #=> "name"
user.setting.send_email #=> true

# 更新処理
user.name = "updated name2"
user.setting.send_email = true
user.save
# usersレコードだけでなく、settingsレコードも更新される、またトランザクション内で実行される
# autosaveオプションが指定されてないとusersレコードのみ更新され、settingsレコードは別途更新が必要
#
#  (0.1ms)  begin transaction
# User Update (0.6ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "updated name"], ["updated_at", "2018-11-25 07:33:50.424390"], ["id", 1]]
# Setting Update (0.2ms)  UPDATE "settings" SET "send_email" = ?, "updated_at" = ? WHERE "settings"."id" = ?  [["send_email", 0], ["updated_at", "2018-11-25 07:33:50.426575"], ["id", 1]]
#  (6.0ms)  commit transaction
# => true

user.reload
user.name #=> "updated name"
user.setting.send_email #=> false

# 削除処理
# mark_for_destructionで削除フラグを立てる
user.setting.marked_for_destruction? # => false
user.setting.mark_for_destruction
user.setting.marked_for_destruction? # => true
user.save
# 削除フラグが立っている関連レコードも削除される
#   (0.1ms)  begin transaction
#  Setting Destroy (1.2ms)  DELETE FROM "settings" WHERE "settings"."id" = ?  [["id", 1]]
#   (0.9ms)  commit transaction
# => true

2. Autosaveでのメソッドやコールバックを定義している箇所

まず、autosaveオプションを指定することで定義されるメソッドをみます。
メソッドの作成や、バリデーション/保存/更新のコールバックを追加されます。

class User < ApplicationRecord
  has_one :setting, autosave: true

  # 下記、自動的に定義されるバリデーション、コールバック、メソッド
  mattr_accessor :index_nested_attribute_errors, instance_writer: false, default: false

  validate :validate_associated_records_for_setting
  after_validation :_ensure_no_duplicate_errors

  after_create :autosave_associated_records_for_setting
  after_update :autosave_associated_records_for_setting

  def validate_associated_records_for_setting
    validate_single_association(author)
  end

  def autosave_associated_records_for_setting
    save_has_one_association(setting)
  end
end

add_autosave_association_callbacksクラスメソッドで、関連モデルのバリデーションやコールバックを追加しています。

# rails/activerecord/lib/active_record/autosave_association.rb
module ActiveRecord
  module AutosaveAssociation
    extend ActiveSupport::Concern

    included do
      Associations::Builder::Association.extensions << AssociationBuilderExtension
      mattr_accessor :index_nested_attribute_errors, instance_writer: false, default: false
    end

    module ClassMethods # :nodoc:

        # 保存メソッドの定義とコールバックを追加
        def add_autosave_association_callbacks(reflection)
          save_method = :"autosave_associated_records_for_#{reflection.name}"

          # 関連がhas_manyといったコレクションの場合
          if reflection.collection?
            before_save :before_save_collection_association
            after_save :after_save_collection_association

            define_non_cyclic_method(save_method) { save_collection_association(reflection) }

            after_create save_method
            after_update save_method

          # 関連がhas_oneの場合
          elsif reflection.has_one?
            define_method(save_method) { save_has_one_association(reflection) } unless method_defined?(save_method)

            after_create save_method
            after_update save_method

          # 関連がbelongs_toの場合
          else
            define_non_cyclic_method(save_method) { throw(:abort) if save_belongs_to_association(reflection) == false }

            before_save save_method
          end

          # バリデーションのメソッドとコールバックを追加
          define_autosave_validation_callbacks(reflection)
        end

        # バリデーションのメソッドとコールバックを追加
        def define_autosave_validation_callbacks(reflection)
          validation_method = :"validate_associated_records_for_#{reflection.name}"
          if reflection.validate? && !method_defined?(validation_method)
            if reflection.collection?
              method = :validate_collection_association
            else
              method = :validate_single_association
            end

            define_non_cyclic_method(validation_method) { send(method, reflection) }
            validate validation_method
            after_validation :_ensure_no_duplicate_errors
          end
        end
        
      ...

ちなみに、define_no_cycle_methodは、成功したら実行済みフラグをたてブロック内の処理をなんども実行しないようにしています。

def define_non_cyclic_method(name, &block)
  return if method_defined?(name)
  define_method(name) do |*args|
    result = true; @_already_called ||= {}
    # Loop prevention for validation of associations
    unless @_already_called[name]
      begin
        @_already_called[name] = true
        result = instance_eval(&block)
      ensure
        @_already_called[name] = false
      end
    end

    result
  end
end

3. Autosaveのバリデーション処理

次にバリデーション処理についてみていきます。 自動的に追加されたvalidate_associated_records_for_settingメソッドをvalidateに追加しています。 また、バリデーションのコールバックに_ensure_no_duplicate_errorsを定義しています。

class User < ApplicationRecord
  has_one :setting, autosave: true

  # 下記、自動的に定義されるバリデーション関連のメソッドやコールバックなど
  mattr_accessor :index_nested_attribute_errors, instance_writer: false, default: false

  validate :validate_associated_records_for_setting
  after_validation :_ensure_no_duplicate_errors

  def validate_associated_records_for_setting
    validate_single_association(author)
  end

  ...
end

バリデーション処理のvalidate_single_associationをみていきます。
validate_single_associationは、関連するモデルがあればバリデーションを実施してます。 バリデーションがエラー時には、親モデルのerrorsにエラーメッセージを設定しています。

# rails/activerecord/lib/active_record/autosave_association.rb
module ActiveRecord
  module AutosaveAssociation
    ...

    # 関連モデルがあれば、関連モデルのバリデーションを実施する
    def validate_single_association(reflection)
      association = association_instance_get(reflection.name)
      record      = association && association.reader
      association_valid?(reflection, record) if record
    end

    def association_valid?(reflection, record, index = nil)
      # 削除されたり、削除フラグが立っている場合は、バリデーションをスキップしてバリデーションOKとして返す
      return true if record.destroyed? || (reflection.options[:autosave] && record.marked_for_destruction?)

      context = validation_context unless [:create, :update].include?(validation_context)

      # 関連モデルのvalid?メソッドを呼ぶことでバリデーションを実施する
      # バリデーションがInvalidな場合は、親モデルのerrorsにメッセージを設定する
      unless valid = record.valid?(context)
        if reflection.options[:autosave]
          indexed_attribute = !index.nil? && (reflection.options[:index_errors] || ActiveRecord::Base.index_nested_attribute_errors)

          # errorsにエラーメッセージを追加
          record.errors.each do |attribute, message|
            attribute = normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
            errors[attribute] << message
            errors[attribute].uniq!
          end

          # errors.detailsにもエラーメッセージを追加
          record.errors.details.each_key do |attribute|
            reflection_attribute =
              normalize_reflection_attribute(indexed_attribute, reflection, index, attribute).to_sym

            record.errors.details[attribute].each do |error|
              errors.details[reflection_attribute] << error
              errors.details[reflection_attribute].uniq!
            end
          end
        else
          errors.add(reflection.name)
        end
      end
      valid
    end

    # 関連レコードがhas_one/belongs_toではなく、collectionの場合のために、エラーのキー名を作成する
    def normalize_reflection_attribute(indexed_attribute, reflection, index, attribute)
      if indexed_attribute
        "#{reflection.name}[#{index}].#{attribute}"
      else
        "#{reflection.name}.#{attribute}"
      end
    end

    ...
  end
end

次に、after_validationコールバックでよばれる、_ensure_no_duplicate_errorsを見てみます。
メソッド名からもわかるように、重複するエラーメッセージを、ユニークにしているだけです。

def _ensure_no_duplicate_errors
  errors.messages.each_key do |attribute|
    errors[attribute].uniq!
  end
end

このように、関連するモデルのバリデーションは、親モデルのバリデーション時に関連するモデルのバリデーションを実施し、バリデーションエラーが起きた場合、親のerrorsにエラーのキーを工夫して設定しているような実装でした。

4. Autosaveの作成/更新/削除処理

次に、autosaveの、作成/更新処理についてみていきます。
親モデルが作成、更新されたときに、コールバックでsave_has_one_associationが呼ばれます。

class User < ApplicationRecord
  has_one :setting, autosave: true

  # 下記、自動的に定義される作成/更新処理のメソッド、コールバック
  after_create :autosave_associated_records_for_setting
  after_update :autosave_associated_records_for_setting

  def autosave_associated_records_for_setting
    save_has_one_association(setting)
  end
end

save_has_one_associationは、関連するモデルのレコードを作成、更新、削除をします。
削除は、marked_for_destruction が実行されて削除フラグがたっているときに実行します。

# rails/activerecord/lib/active_record/autosave_association.rb
module ActiveRecord
  module AutosaveAssociation

    ...

    # 削除フラグをたてる
    def mark_for_destruction
      @marked_for_destruction = true
    end

    # 削除フラグがたっているかいなか
    def marked_for_destruction?
      @marked_for_destruction
    end

    private

      ...

      # 新規やautosaveが有効な場合は、関連レコードを保存する
      # また、#mark_for_destruction で削除フラグがたてられている時はレコードを削除する
      # この更新処理はトランザクション内で実行される
      def save_has_one_association(reflection)
        association = association_instance_get(reflection.name)
        record      = association && association.load_target

        # 既に削除済みの場合は処理をスキップ
        if record && !record.destroyed?
          autosave = reflection.options[:autosave]

          # #marked_for_destruction で削除フラグたてられている場合はレコードから削除する
          if autosave && record.marked_for_destruction?
            record.destroy
          # marked_for_destructionがたてられていない場合は、作成や更新をする
          elsif autosave != false
            key = reflection.options[:primary_key] ? send(reflection.options[:primary_key]) : id

            # 新規やモデルに変更があれば保存処理をバリデーションなしで実施する
            if (autosave && record.changed_for_autosave?) || new_record? || record_changed?(reflection, record, key)
              unless reflection.through_reflection
                record[reflection.foreign_key] = key
                if inverse_reflection = reflection.inverse_of
                  record.association(inverse_reflection.name).loaded!
                end
              end

              saved = record.save(validate: !autosave)
              raise ActiveRecord::Rollback if !saved && autosave
              saved
            end
          end
        end
      end

      # レコードが新規か変更がある場合はtrueを返す
      def record_changed?(reflection, record, key)
        record.new_record? ||
          (record.has_attribute?(reflection.foreign_key) && record[reflection.foreign_key] != key) ||
          record.will_save_change_to_attribute?(reflection.foreign_key)
      end

このように、関連するモデルの作成/更新/削除処理は、親モデルの作成/更新コールバックに一緒に実行することで、トランザクションないで実行することができ、データ不整合がおこらないようにしています。
削除処理は顕著で、トランザクション内で削除を実行できるようにするため、marked_for_destructionメソッドで削除フラグをたてるような実装にしています。

参考情報