Mongoidの隠れクエリメソッドfind_by!とraise_not_found_errorオプションの関係

Mongoidには実は`find_by!`というクエリメソッドがあるのだが、[公式ドキュメントのクエリセクション](https://docs.mongodb.com/ruby-driver/master/tutorials/5.1.0/mongoid-queries/)にはこの説明が載っていない。
かつ、mongoid.ymlで指定できる`raise_not_found_error`オプションと、`find`, ` find_by`, `find_by!`の関係にちょっと癖があったので、それらをまとめるためにこの記事を残す。

# find_by!
`find_by!`の存在を知るためには、まずソースコードを読む必要がある。

[lib/mongoid/findable.rb#L134](https://github.com/mongodb/mongoid/blob/71c29a80599087008ecfbd057d3d049c2153c7ce/lib/mongoid/findable.rb#L134)にその記述がある

```rb
    # Find the first +Document+ given the conditions, or raises
    # Mongoid::Errors::DocumentNotFound
    #
    # @example Find the document by attribute other than id
    #   Person.find_by(:username => "superuser")
    #
    # @param [ Hash ] attrs The attributes to check.
    #
    # @raise [ Errors::DocumentNotFound ] If no document found.
    #
    # @return [ Document ] A matching document.
    #
    def find_by!(attrs = {})
      result = where(attrs).find_first
      raise(Errors::DocumentNotFound.new(self, attrs)) unless result
      yield(result) if result && block_given?
      result
    end
```
メソッドのなかでは`Mongoid.raise_not_found_error`をチェックする条件分岐がないので、**その設定にかかわらず`Mongoid::Errors::DocumentNotFound`を投げる**ことが分かる。

# find_by
`find_by`の実装は同ファイル中の[lib/mongoid/findable.rb#L113](https://github.com/mongodb/mongoid/blob/71c29a80599087008ecfbd057d3d049c2153c7ce/lib/mongoid/findable.rb#L113)にある。

```rb
    # Find the first +Document+ given the conditions.
    # If a matching Document is not found and
    # Mongoid.raise_not_found_error is true it raises
    # Mongoid::Errors::DocumentNotFound, return null nil elsewise.
    #
    # @example Find the document by attribute other than id
    #   Person.find_by(:username => "superuser")
    #
    # @param [ Hash ] attrs The attributes to check.
    #
    # @raise [ Errors::DocumentNotFound ] If no document found
    # and Mongoid.raise_not_found_error is true.
    #
    # @return [ Document, nil ] A matching document.
    #
    # @since 3.0.0
    def find_by(attrs = {})
      result = where(attrs).find_first
      if result.nil? && Mongoid.raise_not_found_error
        raise(Errors::DocumentNotFound.new(self, attrs))
      end
      yield(result) if result && block_given?
      result
    end
```
`find_by`の挙動はドキュメントによると
> Criteria#find_by
>
Find a document by the provided attributes, and if not found raise an error or return nil **depending on the * ``raise_not_found_error`` *configuration option.**

とのことだが、なるほど確かに実装をみると条件分岐中で`Mongoid.raise_not_found_error`の値をチェックしているのが分かる。

# find
ここまではよろしかったが、このfindが問題である。findは実装上複数のメソッドに分けて隠蔽されていて、少し細かくみていかねばならない。

`find`自体の実装はまず[lib/mongoid/criteria/findable.rb#L37](https://github.com/mongodb/mongoid/blob/71c29a80599087008ecfbd057d3d049c2153c7ce/lib/mongoid/criteria/findable.rb#L37)に記述されている

```rb
      # Find the matchind document(s) in the criteria for the provided ids.
      #
      # @example Find by an id.
      #   criteria.find(BSON::ObjectId.new)
      #
      # @example Find by multiple ids.
      #   criteria.find([ BSON::ObjectId.new, BSON::ObjectId.new ])
      #
      # @param [ Array<BSON::ObjectId> ] args The ids to search for.
      #
      # @return [ Array<Document>, Document ] The matching document(s).
      #
      # @since 1.0.0
      def find(*args)
        ids = args.__find_args__
        raise_invalid if ids.any?(&:nil?)
        for_ids(ids).execute_or_raise(ids, args.multi_arged?)
      end
```
処理の中心になる`execute_or_raise`はその上の行で定義されている。

```rb
      # Execute the criteria or raise an error if no documents found.
      #
      # @example Execute or raise
      #   criteria.execute_or_raise(id)
      #
      # @param [ Object ] args The arguments passed.
      #
      # @raise [ Errors::DocumentNotFound ] If nothing returned.
      #
      # @return [ Document, Array<Document> ] The document(s).
      #
      # @since 2.0.0
      def execute_or_raise(ids, multi)
        result = multiple_from_db(ids)
        check_for_missing_documents!(result, ids)
        multi ? result : result.first
      end
```
さらに`check_for_missing_documents!`が出てくる。
定義先は[lib/mongoid/criteria.rb#L455](https://github.com/mongodb/mongoid/blob/71c29a80599087008ecfbd057d3d049c2153c7ce/lib/mongoid/criteria.rb#L455)らしい。

```rb
    # Are documents in the query missing, and are we configured to raise an
    # error?
    #
    # @api private
    #
    # @example Check for missing documents.
    #   criteria.check_for_missing_documents!([], [ 1 ])
    #
    # @param [ Array<Document> ] result The result.
    # @param [ Array<Object> ] ids The ids.
    #
    # @raise [ Errors::DocumentNotFound ] If none are found and raising an
    #   error.
    #
    # @since 3.0.0
    def check_for_missing_documents!(result, ids)
      if (result.size < ids.size) && Mongoid.raise_not_found_error
        raise Errors::DocumentNotFound.new(klass, ids, ids - result.map(&:_id))
      end
    end
```
result配列が空なら例外を上げるという簡単な挙動であることが分かる。
しかし、ここで`find`メソッドに関するドキュメントを見てみたい。

>Criteria#find
>
Find a document or multiple documents by their ids. **Will raise an error by default** if any of the ids do not match.

つまり、`find`メソッドも「デフォルトでは例外を上げる」という説明があるのだが、はたしてその挙動が`raise_not_found_error`オプションの影響を受けるということが書いていないので、注意が必要である。

# まとめ
|                                |              例外を上げる        |    例外をあげない      |
|:------------------------------:|:------------------------------:|:-------------------:|
|raise_not_found_error: **true** | `find`, `find_by`, `find_by!`, |       (なし)         |
|raise_not_found_error: **false**| `find_by!`                     | `find`, `find_by`    |
ActiveRecordと違って、`raise_not_found_error`というオプションがあるので注意して使いたい:fist: