mongodb/mongoid を読む - embedded オブジェクト更新時の永続化データの自動更新挙動
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)
を用いる
- Document ID変わってもよい
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がコールされたときに主に行うことは
- get_relation で現在値を取得
- 現在値の substitute メソッドをコールして 新しい値を渡す
- 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