Entendendo os contadores no Ruby on Rails

Aparentemente, os counters são as coisas que mais geram confusão para quem está começando no Ruby on Rails. Afinal, existem diversas maneiras de se obter o total de itens de uma coleção. Imagine que você possua dois modelos:

class User < ActiveRecord::Base
  has_many :things
end

class Thing < ActiveRecord::Base
  belongs_to :user
end

Vamos popular o banco de dados com alguns registros. À partir do console, execute os comandos abaixo.

user = User.create
100.times { user.items.create }

A primeira maneira e que poderá causar problemas é carregando todos os itens da coleção em memória, para depois usar o método size. Ela é ruim se você possuir uma base de dados com muitos registros. Pensando bem, ela é ruim sempre!

user.things.all.size
# => SELECT * FROM "things" WHERE ("things".user_id = 1)

Você também pode utilizar o método length. Ele funciona como o método acima, só que de maneira mais automática.

user.things.length
# => SELECT * FROM "things" WHERE ("things".user_id = 1)

Outra maneira é utilizar o método size diretamente na associação. Ela irá gerar uma consulta SQL para fazer a contagem.

user.things.size
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1)

Assim como o método size, o método count também irá gerar uma consulta SQL para fazer a consulta. A diferença é que você pode enviar condições, já que o método size não permite que você faça isso.

user.things.count
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1)

user.things.count(:conditions => ["created_at > ?", 2.days.ago])
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1 AND (created_at > '2008-11-30 10:26:05'))

Estamos no caminho. A saída, pelos exemplos acima, é utilizar sempre o método count. Uma grande desvantagem, no entanto, é que isso trará problemas de performance quando você possuir uma base de milhões de registros fazendo counts para todo lado. Para nossa sorte, o Rails possui um recurso chamado counter cache.

O counter cache nada mais é que um campo na tabela que irá refletir o total de itens de um relacionamento, evitando uma consulta SQL para isso. Toda vez que um registro é criado, este campo é incrementado; quando um registro é destruído, esse campo é decrementado. A única desvatangem do counter cache é que ele só pode ser utilizado em relacionamentos.

Para adicionar um counter cache, basta adicionar um campo que segue o padrão [associação]_count. Lembre-se de atualizar a contagem após adicionar o campo.

class AddThingsCountToUser < ActiveRecord::Migration
  def self.up
    add_column :users, :things_count, :integer, :default => 0, :null => false

    User.all.each do |user|
      User.update_counters user.id, :things_count => user.things.count
    end
  end

  def self.down
    remove_column :users, :things_count
  end
end

Para que o Rails saiba que este campo existe, você deve adicionar a opção :counter_cache => true.

class Thing < ActiveRecord::Base
  belongs_to :user, :counter_cache => true
end

Agora, toda vez que um registro for criado ou destruído, o Rails irá incrementar/decrementar este campo automaticamente. E toda vez que você utilizar o método count, ele irá utilizar o valor armazenado neste campo. Vamos criar um novo registro para ver a consulta gerada:

user.things.create
# => INSERT INTO "things" ("updated_at", "user_id", "created_at") VALUES('2008-12-02 10:59:25', 1, '2008-12-02 10:59:25')
# => UPDATE "users" SET "things_count" = COALESCE("things_count", 0) + 1 WHERE ("id" = 1)

Para saber quantas coisas um usuário possui, você deve utilizar o método size. Se você utilizar o método count, a consulta será feita diretamente no banco. Na dúvida, utilize o método things_count.

user.things.size
# => 101

user.things.count
# => 101
# => SELECT count(*) AS count_all FROM "things" WHERE ("things".user_id = 1)

user.things_count
# => 101

Se precisa trabalhar com contadores personalizados (que dependem de condições para serem incrementados ou não), pode utilizar callbacks no seu modelo juntamente com os métodos increment_counter e decrement_counter. Imagine que o modelo User possua um contador chamado cool_things_count que refletirá o total de registros com o atributo kind com o valor cool.

class Thing < ActiveRecord::Base
  belongs_to :user, :counter_cache => true

  after_save :increment_cool_things_count
  after_destroy :decrement_cool_things_count

  private
    def increment_cool_things_count
      User.increment_counter(:cool_things_count, user_id) if kind == 'cool'
    end

    def decrement_cool_things_count
      User.decrement_counter(:cool_things_count, user_id) if kind == 'cool'
    end
end

É importante notar que você não pode usar nenhum relacionamento quando estiver tratando o callback after_destroy, já que o objeto não existe mais.