Rails: increment counter cache and return value
Small ActiveRecord hack that you can use everyday.
Sometimes you cannot use included in Rails Associations #counter_cache
method. E.g. we have a polymorphic model Comment
that belongs to its #target
, some targets have #comments_count
columns, others - not.
# app/models/comment.rb
class Comment < ActiveRecord::Base
belongs_to :target, polymorphic: true, inverse_of: :comments, counter_cache: true
# …
end
In this case if #target
does not have #comments_count
column, ActiveRecord::StatementInvalid
exception will be raised. This can be solved with association callbacks or better to move Comment
create logic to specific service object where you also will increment counters only in appropriate targets. In both cases you can use #increment_counter
method which accepts column name, that should be incremented and id of a record in a database.
# your comment create logic here
document = comment.target
Document.increment_counter(:comments_count, document.id)
It works pretty well, increments counter in SQL using COALESCE
function and + 1
operator. The only problem for me in this case is that I cannot get actual number of comments for selected document, because by default PostgreSQL (I believe that other RDBMS too return number of modified rows). To do this I had either to reload document or find it in database again.
document.reload
# or…
document = Document.find(document.id)
Both seems weird for me… Fortunately PostgreSQL can return data from modified rows. Bingo! Lets create our custom method that will update #comments_count
on Document.all
# app/models/document.rb
# …
def increment_comments_count!
result = ActiveRecord::Base.connection.execute <<~SQL
UPDATE
"#{self.class.table_name}"
SET
"comments_count" = COALESCE("comments_count", 0) + 1
WHERE
"#{self.class.table_name}"."id" = #{id}
RETURNING
comments_count;
SQL
write_attribute(:comments_count, result[0]['comments_count'])
clear_attribute_changes(:comments_count)
self
end
# …
As you can see we execute SQL Update query and ask DB to return modified comments_count
column. Then we get modified value from result
object (it is and instance of PGResult) and assign it to our model. Rails magic, which I personally hate (hello to ROM and all dry-rb stack), begins in clear_attribute_changes
, which removes changed comments_count
from Dirty attributes, because in this case model is outdated compared to actual value in DB.
# app/services/comment/create_comment.rb
# …
if form.validate(params)
comment = form.sync
ActiveRecord::Base.transaction(requires_new: true) do
comment.save!
document.increment_comments_count!
end
[:success, [document, comment]]
else
[:failure, :validation, form]
end
# …
With this code we can guarantee, that document
will have correct comments_count
value. Finally, lets move code to separate module, that will extend our models.
# app/models/concerns/increment_counter_with_returning_value.rb
module IncrementCounterWithReturningValue
def increment_counter_returning_value(counter, id)
query = "
UPDATE \"#{table_name}\"
SET
\"#{counter}\" = COALESCE(\"#{counter}\", 0) + 1
WHERE
\"#{table_name}\".\"#{primary_key}\" = #{connection.quote(id)}
RETURNING
\"#{counter}\";
"
result = connection.execute(query, "#{class_name.to_s} increment #{counter}")
result[0][counter.to_s]
end
end
# app/models/document.rb
class Document < ActiveRecord::Base
extend IncrementCounterWithReturningValue
# …
def increment_comments_count!
write_attribute(:comments_count, self.class.increment_counter_returning_value(:comments_count, id))
clear_attribute_changes(:comments_count)
self
end
end
Where this can be used? Of course in situations like above, when you have to in/decrement cache column. Also in SaaS platforms and e-commerce projects you need to update user internal balance or update product availability.
Don't hesitate to hack (safely) ActiveRecord!