Active Record の size vs count vs length をコードレベルで見てみた

Ruby on Rails の `Active Record` でいつも何気なく `size` を使っていたら、なぜかデータベースにエントリがあるはずなのに正しく数を数えられていない症状が起きました。

もう結構出ている話なのですが、`size` って一度ロードしたデータをロードし直してくれないんですね。

せっかくなのでコードレベルで詳しく調べてみました。

## `size` メソッド

まずは [`size` の定義 :octocat:](https://github.com/rails/rails/blob/8e3b1a632c15e8c7f708ab222a81faaad8e3410e/activerecord/lib/active_record/associations/collection_association.rb#L269) から。

```ruby
# Returns the size of the collection by executing a SELECT COUNT(*)
# query if the collection hasn't been loaded, and calling
# <tt>collection.size</tt> if it has.
#
# If the collection has been already loaded +size+ and +length+ are
# equivalent. If not and you are going to need the records anyway
# +length+ will take one less query. Otherwise +size+ is more efficient.
#
# This method is abstract in the sense that it relies on
# +count_records+, which is a method descendants have to provide.
def size
  if !find_target? || loaded?
    if association_scope.distinct_value
      target.uniq.size
    else
      target.size
    end
  elsif !loaded? && !association_scope.group_values.empty?
    load_target.size
  elsif !loaded? && !association_scope.distinct_value && target.is_a?(Array)
    unsaved_records = target.select(&:new_record?)
    unsaved_records.size + count_records
  else
    count_records
  end
end
```

関数の始めにいきなりありますね。

```ruby
if !find_target? || loaded?
  if association_scope.distinct_value
    target.uniq.size
  else
    target.size
  end
elsif
```

つまり `loaded?` だと既にロードしてある `target` の  `size` を返すようです。
ちなみに `loaded?` はこのクラスの親クラスである [`Association`](https://github.com/rails/rails/blob/8e3b1a632c15e8c7f708ab222a81faaad8e3410e/activerecord/lib/active_record/associations/association.rb) で定義されており、シンプルにインスタンス変数 `@loaded` の値を返します。


## `count` メソッド

次に [count メソッド :octocat:](https://github.com/rails/rails/blob/8e3b1a632c15e8c7f708ab222a81faaad8e3410e/activerecord/lib/active_record/associations/collection_association.rb#L218) の定義です。

```ruby
# Count all records using SQL. Construct options and pass them with
# scope to the target class's +count+.
def count(column_name = nil)
  relation = scope
  if association_scope.distinct_value
    # This is needed because 'SELECT count(DISTINCT *)..' is not valid SQL.
    column_name ||= reflection.klass.primary_key
    relation = relation.distinct
  end
  value = relation.count(column_name)
  limit = options[:limit]
  offset = options[:offset]
  if limit || offset
    [ [value - offset.to_i, 0].max, limit.to_i ].min
  else
    value
  end
end
```

確かに `value = relation.count(column_name)` でデータベースへ count を発行しており、インスタンス変数とかの保存しているところを読んでいる感じではないですね。


## `length` メソッド

で、もう1つ似ているメソッドで `length` がありますね。こちらも [`length` の定義 :octocat:](https://github.com/rails/rails/blob/8e3b1a632c15e8c7f708ab222a81faaad8e3410e/activerecord/lib/active_record/associations/collection_association.rb#L296) を見てみると

```ruby
# Returns the size of the collection calling +size+ on the target.
#
# If the collection has been already loaded +length+ and +size+ are
# equivalent. If not and you are going to need the records anyway this
# method will take one less query. Otherwise +size+ is more efficient.
def length
  load_target.size
end
```

一見 `size` を呼んでいるだけのように見えますが、コメントにもあるように微妙にパフォーマンスが異なります。

`length` は `load target` をしてから `size` を呼ぶために必ずデータが読み込まれていることになります。

`size` を呼ぶと必ずしもデータをロードするわけではなく、その後の処理でデータが必要になった場合に（Rails を使う開発者が）再度ロードするだろうから、もう1クエリー増える、とのこと。

数を数える、という簡単な処理でもいろいろあるものですね。Rails は本当に奥が深いです。