LoginSignup
7
2

More than 5 years have passed since last update.

Mongoidのrelationのトリッキーな挙動

Posted at

Mongoidでrelationを取ってくる際、非常にトリッキーな挙動をしていてハマった。黒魔術の一端を垣間見てしまったので、ここでその記録を残しておく。
なおこの問題は普通にMongoidを使っていればまず遭遇しない問題で、99%の人には無関係だと思われる

環境

問題となる挙動

1:Nの関係を持つ二つのモデルを考える。ここではArticleCommentとする。

models.rb
class Article
  has_many :comments
  ...
end

class Comment
  belongs_to :article
  ...
end

ArticleからCommentを取得することを考える。この時にcommentsメソッドを使うが、この返り値が問題となる。
IRBなどで実行すると

article = Article.find( ...ID... )
article.comments
# => [ #<Comment _id: .... >, #<Comment _id: ....>, .... ]

というようにまるでArrayかのように一見振る舞う。しかし実態は異なっており、これはinspectメソッドがオーバーライドされているだけである。

mongoid/relations/targets/enumerable.rb
  def inspect
    entries.inspect
  end

じゃあ、実際に何のクラスが返ってくるか調べてみる。しかし、ここで変な挙動に出くわす。

comments = article.comments
comments.class                        #=> Mongoid::Relations::Targets::Enumerable
comments.is_a?(Array)                 #=> true
comments.is_a?(Mongoid::Relations::Targets::Enumerable)  #=> false

あれ???なんだこれは?

ひょっとして Mongoid::Relations::Targets::Enumerable がArrayから継承しているのかと思ったが違う様子。

Mongoid::Relations::Targets::Enumerable.ancestors.include?(Array)    # => false

よくよくソースコードを読んでみたらis_a?を明示的に委譲していた。
https://github.com/mongodb/mongoid/blob/v5.1.4/lib/mongoid/relations/targets/enumerable.rb#L19

mongoid/relations/targets/enumerable.rb
  delegate :is_a?, :kind_of?, to: []

そ、そういうことか。。。

commentsは何のインスタンスなのか?

じゃあcommentsは「Mongoid::Relations::Targets::Enumerableのインスタンスなのだろう」と思いたくなるのだが、実は違う
別のクラス Mongoid::Relations::Referenced::Many のインスタンスなのである。

一体何が起きているかを順番に追ってみる。
まず、Mongoid::Relations::Referenced::ManyMongoid::Relations::Many を継承しており、それはさらに Mongoid::Relations::Proxyを継承している。
このProxyが黒魔術的なことをしている。

まずProxyはほとんどのpublic_methodをundefしている。例外的に(send,object_id,respond_to)などの一部のメソッドは残されている。

mongoid/relations/proxy.rb
      # We undefine most methods to get them sent through to the target.
      instance_methods.each do |method|
        undef_method(method) unless
          method =~ /(^__|^send|^object_id|^respond_to|^tap|^public_send|extend_proxy|extend_proxies)/
      end

じゃあProxyインスタンスに対するメソッドはどのように呼ばれるかというと、保持しているtargetというインスタンス変数にmethod_missing経由で送られる。

mongoid/relations/referenced/many.rb
        def method_missing(name, *args, &block)
          if target.respond_to?(name)
            target.send(name, *args, &block)
          else
            klass.send(:with_scope, criteria) do
              criteria.public_send(name, *args, &block)
            end
          end
        end

なので、#classメソッドは最初のif文がtrueになり、@targetというインスタンス変数に対して呼ばれている。
この@targetMongoid::Relations::Targets::Enumerable のインスタンスなので、別のclassが返っていたというわけである。

どういう場合に問題がおきるか?

大概の場合はこのような奇妙な動作も意識せずに使えるのだが、オブジェクトに対してモンキーパッチをしようとすると問題が起きる。

例えば、Objectに対してMessagePackのシリアライズのメソッドを定義したいとしよう。

monkey_patch.rb
Object.class_eval do
  def to_msgpack
    MessagePack.pack( [self.id, self.class] )
  end
end

そして、 article.comments.to_msgpack を呼ぶとどうなるかが問題である。
これはファイルがrequireされた順番に依存してしまう。
もしMongoidがrequireされてから、"monkey_patch.rb"をrequrieするのであれば問題無い。

しかし、逆の順番で"monkey_patch.rb"をrequireしてからMongoidをするとまずいことが起きる。
まず、Objectに対してto_msgpackメソッドが定義される。
しかし、Mongoidの"proxy.rb"が呼ばれた時に、to_msgpackがundefされてしまう。
この状態で、to_msgpackが呼ばれると、#method_missingが呼ばれてしまい、処理がMongoid::Relations::Targets::Enumerableのインスタンスに委譲されてしまう。
本当は Mongoid::Relations::Referenced::Manyのインスタンスに対してメソッドを呼びたかったのに別のインスタンスに対してメソッドが呼ばれてしまうことになる。

解決法としては、"monkey_patch.rb"をrequireするタイミングをMongoidを呼んだ後にすることである。
そうすればこの問題は発生しないはずである。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2