Entendendo os contadores no Ruby on Rails


Leia em 2 minutos

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.