# File lib/counter_culture/reconciler.rb, line 228 def primary_key_select relation_class_table_name = quote_table_name(relation_class.table_name) Array.wrap(relation_class.primary_key).map { |pk| "#{relation_class_table_name}.#{pk}" }.join(', ') end
class CounterCulture::Reconciler::Reconciliation
Attributes
Public Class Methods
Source
# File lib/counter_culture/reconciler.rb, line 65 def initialize(counter, changes_holder, options, relation_class) @counter, @options, = counter, options @relation_class = relation_class @changes_holder = changes_holder @connection_handler = WithConnection.new(relation_class) end
Public Instance Methods
Source
# File lib/counter_culture/reconciler.rb, line 72 def perform log "Performing reconciling of #{counter.model}##{counter.relation.to_sentence}." # if we're provided a custom set of column names with conditions, use them; just use the # column name otherwise # which class does this relation ultimately point to? that's where we have to start scope = relation_class counter_column_names = case column_names when Proc if column_names.lambda? && column_names.arity == 0 column_names.call else column_names.call(options.fetch(:context, {})) end when Hash column_names else { nil => counter_cache_name } end raise ArgumentError, ":column_names must be a Hash of conditions and column names" unless counter_column_names.is_a?(Hash) if options[:column_name] counter_column_names = counter_column_names.select do |_, v| options[:column_name].to_s == v.to_s end end # iterate over all the possible counter cache column names counter_column_names.each do |where, column_name| # if the column name is nil, that means those records don't affect # counts; we don't need to do anything in that case. but we allow # specifying that condition regardless to make the syntax less # confusing next unless column_name relation_class_table_name = quote_table_name(relation_class.table_name) # select join column and count (from above) as well as cache column ('column_name') for later comparison counts_query = scope.select( "#{primary_key_select}, " \ "#{association_primary_key_select}, " \ "#{count_select} AS count, " \ "MAX(#{relation_class_table_name}.#{column_name}) AS #{column_name}" ) # we need to join together tables until we get back to the table this class itself lives in join_clauses(where).each do |join| counts_query = counts_query.joins(join) end # iterate in batches; otherwise we might run out of memory when there's a lot of # instances and we try to load all their counts at once batch_size = options.fetch(:batch_size, CounterCulture.config.batch_size) counts_query = counts_query.where(options[:where]).group(full_primary_key(relation_class)) find_in_batches_args = { batch_size: batch_size } find_in_batches_args[:start] = options[:start] if options[:start].present? find_in_batches_args[:finish] = options[:finish] if options[:finish].present? with_reading_db_connection do counts_query.find_in_batches(**find_in_batches_args).with_index(1) do |records, index| log "Processing batch ##{index}." # now iterate over all the models and see whether their counts are right update_count_for_batch(column_name, records) log "Finished batch ##{index}." end end end log_without_newline "\n" log "Finished reconciling of #{counter.model}##{counter.relation.to_sentence}." end
Private Instance Methods
Source
# File lib/counter_culture/reconciler.rb, line 233 def association_primary_key_select relation_class_table_name = quote_table_name(relation_class.table_name) association_primary_key = relation_reflect(relation).association_primary_key(relation_class) Array.wrap(association_primary_key).map { |apk| "#{relation_class_table_name}.#{apk}" }.join(', ') end
Source
# File lib/counter_culture/reconciler.rb, line 211 def count_select # if a delta column is provided use SUM, otherwise use COUNT return @count_select if @count_select if delta_column # cast the column as NUMERIC if it is a PG money type if model.type_for_attribute(delta_column).type == :money @count_select = "SUM(COALESCE(CAST(#{self_table_name}.#{delta_column} as NUMERIC),0))" else @count_select = "SUM(COALESCE(#{self_table_name}.#{delta_column}, 0))" end else primary_key = Array.wrap(model.primary_key).first count_column = "#{self_table_name}.#{primary_key}" @count_select = "COUNT(#{count_column})*#{delta_magnitude}" end end
Source
# File lib/counter_culture/reconciler.rb, line 250 def join_clauses(where) # we need to work our way back from the end-point of the relation to # this class itself; make a list of arrays pointing to the # second-to-last, third-to-last, etc. reverse_relation = (1..relation.length).to_a.reverse. inject([]) { |a, i| a << relation[0, i]; a } # store joins in an array so that we can later apply column-specific # conditions join_clauses = reverse_relation.each_with_index.map do |cur_relation, index| reflect = relation_reflect(cur_relation) target_table = quote_table_name(reflect.active_record.table_name) target_table_alias = parameterize(target_table) if relation_class.table_name == reflect.active_record.table_name # join with alias to avoid ambiguous table name in # self-referential models target_table_alias += "_#{target_table_alias}" end if polymorphic? # NB: polymorphic only supports one level of relation (at present) association_primary_key = reflect.association_primary_key(relation_class) source_table = relation_class.table_name else association_primary_key = reflect.association_primary_key source_table = reflect.table_name end source_table = quote_table_name(source_table) source_table_key = association_primary_key target_table_key = reflect.foreign_key if !reflect.belongs_to? # a has_one relation flips the location of the keys on the tables # around (source_table_key, target_table_key) = [target_table_key, source_table_key] end source_table_key = Array.wrap(source_table_key) target_table_key = Array.wrap(target_table_key) join_conditions = source_table_key .zip(target_table_key).map do |source_key, target_key| "#{source_table}.#{source_key} = #{target_table_alias}.#{target_key}" end.join(' AND ') joins_sql = "LEFT JOIN #{target_table} AS #{target_table_alias} "\ "ON #{join_conditions}" # adds 'type' condition to JOIN clause if the current model is a # child in a Single Table Inheritance if reflect.active_record.column_names.include?('type') && !model.descends_from_active_record? joins_sql += " AND #{target_table_alias}.type IN ('#{model.name}')" end if polymorphic? # adds 'type' condition to JOIN clause if the current model is a # polymorphic relation # NB only works for one-level relations joins_sql += " AND #{target_table_alias}.#{reflect.foreign_type} = '#{relation_class.name}'" end if index == reverse_relation.size - 1 # conditions must be applied to the join on which we are counting if where if where.respond_to?(:to_sql) model_primary_key = Array.wrap(model.primary_key) where_select = model_primary_key.map { |pk| "#{model.table_name}.#{pk}" }.join(', ') joins_sql += " AND (#{target_table_alias}.#{model_primary_key.first}) IN (#{where.select(where_select).to_sql})" else joins_sql += " AND (#{model.send(:sanitize_sql_for_conditions, where)})" end end # respect the deleted_at column if it exists if model.column_names.include?('deleted_at') joins_sql += " AND #{target_table_alias}.deleted_at IS NULL" end # respect the discard column if it exists if defined?(Discard::Model) && model.include?(Discard::Model) && model.column_names.include?(model.discard_column.to_s) joins_sql += " AND #{target_table_alias}.#{model.discard_column} IS NULL" end end joins_sql end end
Source
# File lib/counter_culture/reconciler.rb, line 183 def log(message) return unless log? ActiveRecord::Base.logger.info(message) end
Source
# File lib/counter_culture/reconciler.rb, line 195 def log? options[:verbose] && ActiveRecord::Base.logger end
Source
# File lib/counter_culture/reconciler.rb, line 189 def log_without_newline(message) return unless log? ActiveRecord::Base.logger << message if ActiveRecord::Base.logger.info? end
Source
# File lib/counter_culture/reconciler.rb, line 349 def parameterize(string) if ACTIVE_RECORD_VERSION < Gem::Version.new("5.0") string.parameterize('_') else string.parameterize(separator: '_') end end
Source
Source
# File lib/counter_culture/reconciler.rb, line 343 def quote_table_name(table_name) @connection_handler.call do |connection| connection.quote_table_name(table_name) end end
This is only needed in relatively unusal cases, for example if you are using Postgres with schema-namespaced tables. But then it’s required, and otherwise it’s just a no-op, so why not do it?
Source
# File lib/counter_culture/reconciler.rb, line 239 def self_table_name return @self_table_name if @self_table_name @self_table_name = parameterize(model.table_name) if relation_class.table_name == model.table_name @self_table_name = "#{@self_table_name}_#{@self_table_name}" end @self_table_name = quote_table_name(@self_table_name) @self_table_name end
Source
# File lib/counter_culture/reconciler.rb, line 200 def track_change(record, column_name, count) @changes_holder << { :entity => relation_class.name, :what => column_name, :wrong => record.send(column_name), :right => count }.tap do |h| Array.wrap(relation_class.primary_key).each { |pk| h[pk.to_sym] = record.send(pk) } end end
keep track of what we fixed, e.g. for a notification email
Source
# File lib/counter_culture/reconciler.rb, line 150 def update_count_for_batch(column_name, records) ActiveRecord::Base.transaction do records.each do |record| count = record.read_attribute('count') || 0 next if record.read_attribute(column_name) == count track_change(record, column_name, count) updates = [] # this updates the actual counter updates << "#{column_name} = #{count}" # and here we update the timestamp, if so desired if options[:touch] current_time = record.send(:current_time_from_proper_timezone) timestamp_columns = record.send(:timestamp_attributes_for_update_in_model) if options[:touch] != true # starting in Rails 6 this is frozen timestamp_columns = timestamp_columns.dup timestamp_columns << options[:touch] end timestamp_columns.each do |timestamp_column| updates << "#{timestamp_column} = '#{current_time.to_formatted_s(:db)}'" end end with_writing_db_connection do conditions = Array.wrap(relation_class.primary_key).map { |key| [key, record.send(key)] }.to_h relation_class.where(conditions).update_all(updates.join(', ')) end end end end
Source
# File lib/counter_culture/reconciler.rb, line 357 def with_reading_db_connection(&block) if builder = options[:db_connection_builder] builder.call(true, block) else @connection_handler.call(reading: true) do |_conn| yield end end end
Source
# File lib/counter_culture/reconciler.rb, line 367 def with_writing_db_connection(&block) if builder = options[:db_connection_builder] builder.call(false, block) else @connection_handler.call(reading: false) do |_conn| yield end end end