Entendendo os contadores no Ruby on Rails

02 de Dezembro de 2008

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.

Posts relacionados
Conhecendo as opções de cache do Rails 2.1

Comentários #

#1 Levy disse:
02 Dez 08, 11:45AM

Assim fica difícil acrescentar algo ao texto, ele está completo! ;)

Obrigado pelo artigo!

Abraços

#2 Junio Vitorino disse:
02 Dez 08, 01:19PM

Muito massa e muito bem explicado como sempre, valeu Nando.

#3 Claudio disse:
03 Dez 08, 09:52AM

Oi,
Achei muito interessante, rails sempre pensando num passo a frente.
Mas eu sempre fico meio com medo de usar essas propriedades de counter, pois as vezes eu não sei se ela usa exclusão mútua para essa propriedade.
Eu ainda não tive tempo de ver no código rails se ele faz exclusão mútua, vc sabe me dizer?

#4 sql » Blog Archive » Entendendo os contadores no Ruby... disse:
04 Dez 08, 10:11AM

[...] Leia mais direto na fonte: simplesideias.com.br [...]

#5 Antonio disse:
06 Dez 08, 02:29PM

Muito bom! Poderia dar um exemplo simples de como utilizar essa técnica para counters dependentes de condição? por exemplo dependendo do status de "thinks",

Valeu!

#6 Nando Vieira disse:
06 Dez 08, 09:40PM

Antonio, atualizei o post com um exemplo.

#7 Marcio Garcia disse:
01 Jan 09, 08:08PM

Show de bola Nando, no entanto, existe um pequeno problema (nao no seu código) mas como o rails trabalha com o counter caso nao seja o padrao.
Vou colocar aqui o texto com o código que mandei para os desenvolvedores do core do Rails:

Problems with :counter_cache

I found this 'problem' (I think) when was having two self relation on the table with the same table, for instance:

Having a model like that:

create_table :codes do |t|
t.string :code_key, :null => false, :limit => 25
t.string :file_name, :null => false, :limit => 200, :default => ""
t.integer :language_id, :null => false, :default => 10
t.text :code, :null => false
t.string :comment, :limit => 250, :default => ""
t.boolean :private, :null => false, :default => false
t.integer :reply_id
t.integer :reply_id_count, :integer, :default => 0, :null => false
t.timestamps
end

In the class:

# this is a self rellation....
belongs_to :reply, :class_name => "Code", :foreign_key => "reply_id", :counter_cache => true

I'm willing to have with the self rellation the counter for that.
But a problem occours when I'm try to make an insert:

SQLite3::SQLException: no such column: codes_count: UPDATE "codes" SET "codes_count" = COALESCE("codes_count", 0) + 1 WHERE ("id" = 1)

I was surprised that the rails is trying to update a column called: 'codes_count', but I don't have a column with this name!
It is using the :class_name as a prefix of the collumn _count.

#8 Entendendo os contadores no Ruby on Rails : sql disse:
03 Jan 09, 05:46PM

[...] Leia mais deste post no blog de origem: Clique aqui e prestigie o autor [...]

#9 Guilherme Garnier disse:
11 Jan 09, 06:34PM

Muito bom, parabéns pelo post. Muito bem explicado.

#10 Vitor Kiyoshi Arimitsu disse:
18 Abr 09, 02:49AM

Muito bom... Tirou uma pulga atrás da minha orelha!

#11 juniorsatanas disse:
01 Jul 09, 09:30AM

Falou tudo que eu queria saber e ninguem contava..

Deixe um comentário




Este blog usa o Gravatar.


Não é aceito código HTML: adicione-o no pastie.org ou paste.milk-it.net e poste apenas o link.

Se este é seu primeiro comentário, ele terá que ser aprovado antes de ser exibido.