🍉

mongodb/mongoid を読む - embedded オブジェクト更新時の永続化データの自動更新挙動

2021/07/08に公開

MongoDB Ruby の 公式ODM mongodb/mongoid をよく利用しています。

同一Document内にデータを保持する embedded-associations を定義しているモデルにおいて、こちらが意図しないタイミングでデータ更新がされることがあり、内部実装をソースを読んで調べたときのメモ。

読んだコミットID: 3c31c0f0adaa028c6c58a4fe09b81b332d5fcae9

TL;DR

  • 永続化済みのDocumentの embeded リレーションのsetterをコールするたびに、永続化データの更新が行われるよう実装されている。
  • 永続化データの更新を避ける方法として以下が考えられる
    • Document ID変わってもよい Model#dup を用いる
    • Document IDそのままで扱いたい場合 Model#write_attribute(s) を用いる

embedded 周辺を読む

まず、embedded_in, embeds_many, embeds_one マクロの定義は、
Mongoid::Document 内で以下モジュールを順にincludeで定義されている。

Mongoid::Composable
Mongoid::Association
Mongoid::Association::Macros

具体的な定義箇所

lib/mongoid/association/macros.rb

        # 略...

        def embedded_in(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end
	
        def embeds_many(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end

	def embeds_one(name, options = {}, &block)
          define_association!(__method__, name, options, &block)
        end

        # 略...
	
        private

        def define_association!(macro_name, name, options = {}, &block)
          Association::MACRO_MAPPING[macro_name].new(self, name, options, &block).tap do |assoc|
            assoc.setup!
            self.relations = self.relations.merge(name => assoc)
          end
        end

define_association! で各Associationクラスを初期化して setup! をコールしてsetter/getter 等の関数が定義されている。

例えば、embeds_many の setup! 関数の定義は以下のようになっている。

lib/mongoid/association/embedded/embeds_many.rb

        def setup!
          setup_instance_methods!
          @owner_class.embedded_relations = @owner_class.embedded_relations.merge(name => self)
          @owner_class.aliased_fields[name.to_s] = store_as if store_as
          self
        end

        # 略...

        def setup_instance_methods!
          define_getter!
          define_setter!
          define_existence_check!
          define_builder!
          define_creator!
          @owner_class.cyclic = true if cyclic?
          @owner_class.validates_associated(name) if validate?
        end

今回の挙動を知りたかった setter を定義している define_setter! の実装は以下。

lib/mongoid/association/accessors.rb

      # Defines the setter for the association. This does a few things based on
      # some conditions. If there is an existing association, a target
      # substitution will take place, otherwise a new association will be
      # created with the supplied target.
      #
      # @example Set up the setter for the association.
      #   Person.define_setter!(association)
      #
      # @param [ Association ] association The association metadata for the association.
      #
      # @return [ Class ] The class being set up.
      #
      # @since 2.0.0.rc.1
      def self.define_setter!(association)
        name = association.name
        association.inverse_class.tap do |klass|
          klass.re_define_method("#{name}=") do |object|
            without_autobuild do
              if value = get_relation(name, association, object)
                if value.respond_to?(:substitute)
                  set_relation(name, value.substitute(object.substitutable))
                else
                  value = __build__(name, value, association)
                  set_relation(name, value.substitute(object.substitutable))
                end
              else
                __build__(name, object.substitutable, association)
              end
            end
          end
        end
      end

setterがコールされたときに主に行うことは

  1. get_relation で現在値を取得
  2. 現在値の substitute メソッドをコールして 新しい値を渡す
  3. set_relation でsubstituteの返却値でインスタンス変数を更新

substituteメソッドは各Association毎に定義されている。
それぞれ追ってみる。

embeds_one

lib/mongoid/association/embedded/embeds_one/proxy.rb

          # Substitutes the supplied target documents for the existing document
          # in the association.
          #
          # @example Substitute the new document.
          #   person.name.substitute(new_name)
          #
          # @param [ Document ] replacement A document to replace the target.
          #
          # @return [ Document, nil ] The association or nil.
          #
          # @since 2.0.0.rc.1
          def substitute(replacement)
            if replacement != self
              if _assigning?
                _base.add_atomic_unset(_target) unless replacement
              else
                # The associated object will be replaced by the below update if non-nil, so only
                # run the callbacks and state-changing code by passing persist: false in that case.
                _target.destroy(persist: !replacement) if persistable?
              end
              unbind_one
              return nil unless replacement
              replacement = Factory.build(klass, replacement) if replacement.is_a?(::Hash)
              self._target = replacement
              bind_one
              characterize_one(_target)
              _target.save if persistable?
            end
            self
          end

(ざっくり)
persitable?(データ保存済み)なら、新しい値(replacement)として save をコールしているので、データ更新されます。

参考: _base, _target についての定義は以下
lib/mongoid/association/proxy.rb

      # Model instance for the base of the association.
      #
      # For example, if a Post embeds_many Comments, _base is a particular
      # instance of the Post model.
      attr_accessor :_base

      # Model instance for one to one associations, or array of model instances
      # for one to many associations, for the target of the association.
      #
      # For example, if a Post embeds_many Comments, _target is an array of
      # Comment models embedded in a particular Post.
      attr_accessor :_target

embeds_many

lib/mongoid/association/embedded/embeds_many/proxy.rb

          # Substitutes the supplied target documents for the existing documents
          # in the relation.
          #
          # @example Substitute the association's target.
          #   person.addresses.substitute([ address ])
          #
          # @param [ Array<Document> ] docs The replacement docs.
          #
          # @return [ Many ] The proxied association.
          #
          # @since 2.0.0.rc.1
          def substitute(docs)
            batch_replace(docs)
            self
          end

batch_replaceは以下。

lib/mongoid/association/embedded/batchable.rb

        # Batch replace the provided documents as a $set.
        #
        # @example Batch replace the documents.
        #   batchable.batch_replace([ doc_one, doc_two ])
        #
        # @param [ Array<Document> ] docs The docs to replace with.
        #
        # @return [ Array<Hash> ] The inserts.
        #
        # @since 3.0.0
        def batch_replace(docs)
          if docs.blank?
            if _assigning? && !empty?
              _base.add_atomic_unset(first)
              target_duplicate = _target.dup
              pre_process_batch_remove(target_duplicate, :delete)
              post_process_batch_remove(target_duplicate, :delete)
            else
              batch_remove(_target.dup)
            end
          elsif _target != docs
            _base.delayed_atomic_sets.clear unless _assigning?
            docs = normalize_docs(docs).compact
            _target.clear and _unscoped.clear
            inserts = execute_batch_set(docs)
            add_atomic_sets(inserts)
          end
        end

(ざっくり)
新しい値がblank?なら batch_remove ($pullAll) をコール
新しい値がblank?でなくて、現在値と異なるなら、execute_batch_set ($set) をコール
batch_remove, execute_batch_set については以下。
※ _assigning? については後述

        # Batch remove the provided documents as a $pullAll.
        #
        # @example Batch remove the documents.
        #   batchable.batch_remove([ doc_one, doc_two ])
        #
        # @param [ Array<Document> ] docs The docs to remove.
        # @param [ Symbol ] method Delete or destroy.
        #
        # @since 3.0.0
        def batch_remove(docs, method = :delete)
          removals = pre_process_batch_remove(docs, method)
          if !docs.empty?
            collection.find(selector).update_one(
                positionally(selector, "$pullAll" => { path => removals }),
                session: _session
            )
            post_process_batch_remove(docs, method)
          end
          reindex
        end

        # Perform a batch persist of the provided documents with a $set.
        #
        # @api private
        #
        # @example Perform a batch $set.
        #   batchable.execute_batch_set(docs)
        #
        # @param [ Array<Document> ] docs The docs to persist.
        #
        # @return [ Array<Hash> ] The inserts.
        #
        # @since 7.0.0
        def execute_batch_set(docs)
          self.inserts_valid = true
          inserts = pre_process_batch_insert(docs)
          if insertable?
            collection.find(selector).update_one(
                positionally(selector, '$set' => { path => inserts }),
                session: _session
            )
            post_process_batch_insert(docs)
          end
          inserts
        end

batch_remove, execute_batch_setはMongoに対してクエリ投げてデータを更新する処理でした。

参考: insertable?の定義

        # Are we in a state to be able to batch insert?
        #
        # @api private
        #
        # @example Can inserts be performed?
        #   batchable.insertable?
        #
        # @return [ true, false ] If inserts can be performed.
        #
        # @since 3.0.0
        def insertable?
          persistable? && !_assigning? && inserts_valid
        end

embeddedオブジェクト更新時に永続化データの更新を避ける方法

保存済みDocumentのembeddedオブジェクトの永続化データを更新させずに、値を変更したいときがある。データ更新を回避するには、persistable? が false もしくは _assigning? が true になっていればよいので。

  • Document IDが変わってよければ、Model#dup を使い、 persistable? => false にする
  • Document IDそのままで扱いたい場合は、_assigning を利用した上でsetterをコールしている Model#write_attributes Model#write_attribute を用いる

参考: _assigning? の実装 (_assigning のブロック内であれば true判定)

lib/mongoid/threaded/lifecycle.rb

      private

      # Begin the assignment of attributes. While in this block embedded
      # documents will not autosave themselves in order to allow the document to
      # be in a valid state.
      #
      # @example Execute the assignment.
      #   _assigning do
      #     person.attributes = { :addresses => [ address ] }
      #   end
      #
      # @return [ Object ] The yielded value.
      #
      # @since 2.2.0
      def _assigning
        Threaded.begin_execution(ASSIGN)
        yield
      ensure
        Threaded.exit_execution(ASSIGN)
      end

      # Is the current thread in assigning mode?
      #
      # @example Is the current thread in assigning mode?
      #   proxy._assigning?
      #
      # @return [ true, false ] If the thread is assigning.
      #
      # @since 2.1.0
      def _assigning?
        Threaded.executing?(ASSIGN)
      end

参考: write_attributes, write_attributeの実装

lib/mongoid/attributes.rb

    def write_attributes(attrs = nil)
      assign_attributes(attrs)
    end
    
    def assign_attributes(attrs = nil)
      _assigning do
        process_attributes(attrs)
      end
    end

    def write_attribute(name, value)
      field_name = database_field_name(name)

      if attribute_missing?(field_name)
        raise ActiveModel::MissingAttributeError, "Missing attribute: '#{name}'"
      end

      if attribute_writable?(field_name)
        _assigning do
          validate_attribute_value(field_name, value)
          localized = fields[field_name].try(:localized?)
          attributes_before_type_cast[name.to_s] = value
          typed_value = typed_value_for(field_name, value)
          unless attributes[field_name] == typed_value || attribute_changed?(field_name)
            attribute_will_change!(field_name)
          end
          if localized
            attributes[field_name] ||= {}
            attributes[field_name].merge!(typed_value)
          else
            attributes[field_name] = typed_value
          end
          typed_value
        end
      else
        # TODO: MONGOID-5072
      end
    end

検証コード

モデル, サンプルデータ

モデル

class Band
  include Mongoid::Document
  field :name
  embeds_one :label
  embeds_many :albums
end

class Label
  include Mongoid::Document
  field :name
  embedded_in :band
end

class Album
  include Mongoid::Document
  field :name
  embedded_in :band
end

サンプルデータ

band1 = Band.create(
  name: 'B1',
  label: Label.new(name: 'B1_L1'), 
  albums: [Album.new(name: 'B1_A1'), Album.new(name: 'B1_A2')]
)

band2 = Band.create(
  name: 'B2',
  label: Label.new(name: 'B2_L1'), 
  albums: [Album.new(name: 'B2_A1'), Album.new(name: 'B2_A2')]
)

データが自動更新される例

本記事の出発点の永続化データが自動更新される例から

#
# embeds_one をオブジェクトを更新
#

# setterで値更新
band1.label = Label.new(name: 'B1_L1-Updated')
# DBのデータ確認 => 更新されてる
Band.find(band1).label
# => #<Label _id: 60e65cff04997f67956957d7, name: "B1_L1-Updated">

#
# embeds_many をオブジェクトを更新
#

# 新しいアルバムを追加
band1.albums << Album.new(name: 'B1_A3-NEW')
# DBのデータ確認 => 更新されてる
Band.find(band1).albums
# => [#<Album _id: 60e65c4d04997f67956957cf, name: "B1_A1">, #<Album _id: 60e65c4d04997f67956957d0, name: "B1_A2">, #<Album _id: 60e65ee604997f67956957dd, name: "B1_A3-NEW">

データの自動更新 回避方法1 Model#dup

Model#dupにて自動更新を回避した検証したコード

# dupして別オブジェクトに。
band1_tmp = band1.dup

# Document IDは変更される
band1.id == band1_tmp.id
# => false

# 永続化フラグはfalseに。
band1.persisted?
# => true
band1_tmp.persisted?
# => false

# embedsオブジェクトの更新
band1_tmp.label = Label.new(name: 'B1_L1-Updated')
band1_tmp.albums << Album.new(name: 'B1_A3-NEW')

# DBのデータ確認 (更新されていない)
Band.find(band1).label
# => #<Label _id: 60e65fce04997f67956957e5, name: "B1_La1">
Band.find(band1).albums
# => [#<Album _id: 60e65fce04997f67956957e2, name: "B1_A1">, #<Album _id: 60e65fce04997f67956957e3, name: "B1_A2">]

データの自動更新 回避方法2 Model#write_attribute(s)

Model#write_attribute, Model#write_attributesにて自動更新を回避した検証したコード


#
# write_attribute で更新
#

# embeds_one オブジェクトを write_attribute で更新
band1.write_attribute(:label, Label.new(name: 'B1_L1-Updated'))

# DBのデータ確認 (更新されていない)
Band.find(band1).label
# => #<Label _id: 60e661c004997f67956957f3, name: "B1_L1">

# embeds_many オブジェクトを write_attribute で更新
b1_albums = band1.albums.dup
b1_albums << Album.new(name: 'B1_A3-NEW')
band1.write_attribute(:albums, b1_albums)

# DBのデータ確認 (更新されていない)
Band.find(band1).albums
# => [#<Album _id: 60e661c004997f67956957f0, name: "B1_A1">, #<Album _id: 60e661c004997f67956957f1, name: "B1_A2">]

#
# write_attributes で更新
#
new_label = Label.new(name: 'B2_L1-Updated')
new_albums = band2.albums.dup
new_albums << Album.new(name: 'B2_A3-NEW')
band2.write_attributes(:label => new_label, :albums => new_albums) 

# DBのデータ確認 (更新されていない)
Band.find(band2).label
# => #<Label _id: 60e65fca04997f67956957e1, name: "B2_L1">
Band.find(band2).label
# => [#<Album _id: 60e65fca04997f67956957de, name: "B2_A1">, #<Album _id: 60e65fca04997f67956957df, name: "B2_A2">]

おわり

mongodb/mongoid の embedded-associations の自動更新の挙動を調べて、自動更新を避ける方法を検証しました。他に良い方法があれば、コメントなりで教えてもらえると嬉しいです。

Discussion