* 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
		
			
				
	
	
		
			90 lines
		
	
	
		
			2.9 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			90 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
 |