catstodon/app/models/concerns/account_counters.rb
Claire 741d0952b1
Improve account counters handling (#15913)
* Improve account counters handling

* Use ActiveRecord::Base::sanitize_sql to pass values instead of interpolating them

Keep using string interpolation for `key` as it is safe and using
“ActiveRecord::Base::sanitize_sql_hash_for_assignment” would require stitching
bits of SQL in a way that is not more easily checked for safety.

* Add migration hook to catch PostgreSQL versions earlier than 9.5
2021-03-19 13:14:57 +01:00

89 lines
2.9 KiB
Ruby

# frozen_string_literal: true
module AccountCounters
extend ActiveSupport::Concern
ALLOWED_COUNTER_KEYS = %i(statuses_count following_count followers_count).freeze
included do
has_one :account_stat, inverse_of: :account
after_save :save_account_stat
end
delegate :statuses_count,
:statuses_count=,
:following_count,
:following_count=,
:followers_count,
:followers_count=,
:last_status_at,
to: :account_stat
# @param [Symbol] key
def increment_count!(key)
update_count!(key, 1)
end
# @param [Symbol] key
def decrement_count!(key)
update_count!(key, -1)
end
# @param [Symbol] key
# @param [Integer] value
def update_count!(key, value)
raise ArgumentError, "Invalid key #{key}" unless ALLOWED_COUNTER_KEYS.include?(key)
raise ArgumentError, 'Do not call update_count! on dirty objects' if association(:account_stat).loaded? && account_stat&.changed? && account_stat.changed_attribute_names_to_save == %w(id)
value = value.to_i
default_value = value.positive? ? value : 0
# We do an upsert using manually written SQL, as Rails' upsert method does
# not seem to support writing expressions in the UPDATE clause, but only
# re-insert the provided values instead.
# Even ARel seem to be missing proper handling of upserts.
sql = if value.positive? && key == :statuses_count
<<-SQL.squish
INSERT INTO account_stats(account_id, #{key}, created_at, updated_at, last_status_at)
VALUES (:account_id, :default_value, now(), now(), now())
ON CONFLICT (account_id) DO UPDATE
SET #{key} = account_stats.#{key} + :value,
last_status_at = now(),
lock_version = account_stats.lock_version + 1,
updated_at = now()
RETURNING id;
SQL
else
<<-SQL.squish
INSERT INTO account_stats(account_id, #{key}, created_at, updated_at)
VALUES (:account_id, :default_value, now(), now())
ON CONFLICT (account_id) DO UPDATE
SET #{key} = account_stats.#{key} + :value,
lock_version = account_stats.lock_version + 1,
updated_at = now()
RETURNING id;
SQL
end
sql = AccountStat.sanitize_sql([sql, account_id: id, default_value: default_value, value: value])
account_stat_id = AccountStat.connection.exec_query(sql)[0]['id']
# Reload account_stat if it was loaded, taking into account newly-created unsaved records
if association(:account_stat).loaded?
account_stat.id = account_stat_id if account_stat.new_record?
account_stat.reload
end
end
def account_stat
super || build_account_stat
end
private
def save_account_stat
return unless association(:account_stat).loaded? && account_stat&.changed?
account_stat.save
end
end