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!


Igor
Alexandrov

CTO at JetRockets

Explore more of JetRockets