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

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

環境

- Ruby 2.3.1
- Mongoid 5.1.4
    - https://github.com/mongodb/mongoid/tree/v5.1.4

# 問題となる挙動

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

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

class Comment
  belongs_to :article
  ...
end
```

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

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

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

https://github.com/mongodb/mongoid/blob/v5.1.4/lib/mongoid/relations/targets/enumerable.rb#L274

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

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

```rb
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から継承しているのかと思ったが違う様子。

```rb
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

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

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

# commentsは何のインスタンスなのか？

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

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

https://github.com/mongodb/mongoid/blob/v5.1.4/lib/mongoid/relations/proxy.rb

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

```rb: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`経由で送られる。

https://github.com/mongodb/mongoid/blob/v5.1.4/lib/mongoid/relations/referenced/many.rb#L442

```rb: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`というインスタンス変数に対して呼ばれている。
この@targetが `Mongoid::Relations::Targets::Enumerable` のインスタンスなので、別のclassが返っていたというわけである。

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

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

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

```rb: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を呼んだ後にすることである。
そうすればこの問題は発生しないはずである。