Class: CounterCulture::Counter
- Inherits:
-
Object
- Object
- CounterCulture::Counter
- Defined in:
- lib/counter_culture/counter.rb
Constant Summary collapse
- CONFIG_OPTIONS =
[ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude, :execute_after_commit ]
- ACTIVE_RECORD_VERSION =
Gem.loaded_specs["activerecord"].version
Class Method Summary collapse
-
.build_arel_counter_expr(klass, column, delta, column_type) ⇒ Object
Builds an Arel expression for counter updates: COALESCE(col, 0) +/- delta This is a class method so it can be called from CounterCulture.aggregate_counter_updates.
Instance Method Summary collapse
- #attribute_changed?(obj, attr) ⇒ Boolean
-
#change_counter_cache(obj, options) ⇒ Object
increments or decrements a counter cache.
-
#counter_cache_name_for(obj) ⇒ Object
Gets the name of the counter cache for a specific object.
-
#counter_delta_magnitude_for(obj) ⇒ Object
Gets the delta magnitude of the counter cache for a specific object.
- #execute_now_or_after_commit(obj, &block) ⇒ Object
- #first_level_relation_changed?(instance) ⇒ Boolean
-
#first_level_relation_foreign_key ⇒ Object
gets the foreign key name of the relation.
- #first_level_relation_foreign_type ⇒ Object
-
#foreign_key_value(obj, relation, was = false) ⇒ Object
gets the value of the foreign key on the given relation.
-
#full_primary_key(klass) ⇒ Object
the string to pass to order() in order to sort by primary key.
-
#initialize(model, relation, options) ⇒ Counter
constructor
A new instance of Counter.
- #polymorphic? ⇒ Boolean
- #previous_model(obj) ⇒ Object
-
#relation_foreign_key(relation) ⇒ Object
gets the foreign key name of the given relation.
-
#relation_klass(relation, source: nil, was: false) ⇒ Object
gets the class of the given relation.
-
#relation_primary_key(relation, source: nil, was: false) ⇒ Object
gets the primary key name of the given relation.
-
#relation_reflect(relation) ⇒ Object
gets the reflect object on the given relation.
-
#relation_reflect_and_model(relation) ⇒ Object
gets the reflect object on the given relation and the model that defines this reflect.
Constructor Details
#initialize(model, relation, options) ⇒ Counter
Returns a new instance of Counter.
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
# File 'lib/counter_culture/counter.rb', line 10 def initialize(model, relation, ) @model = model @relation = relation.is_a?(Enumerable) ? relation : [relation] @counter_cache_name = .fetch(:column_name, "#{model.name.demodulize.tableize}_count") @column_names = [:column_names] @delta_column = [:delta_column] @foreign_key_values = [:foreign_key_values] @touch = .fetch(:touch, false) @delta_magnitude = [:delta_magnitude] || 1 @with_papertrail = .fetch(:with_papertrail, false) @execute_after_commit = .fetch(:execute_after_commit, false) if @execute_after_commit begin require 'after_commit_action' rescue LoadError fail(LoadError.new( "You need to include the `after_commit_action` gem in your "\ "gem dependencies to use the execute_after_commit option")) end model.include(AfterCommitAction) end end |
Class Method Details
.build_arel_counter_expr(klass, column, delta, column_type) ⇒ Object
Builds an Arel expression for counter updates: COALESCE(col, 0) +/- delta This is a class method so it can be called from CounterCulture.aggregate_counter_updates
455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 |
# File 'lib/counter_culture/counter.rb', line 455 def self.build_arel_counter_expr(klass, column, delta, column_type) arel_column = klass.arel_table[column] arel_delta = Arel::Nodes.build_quoted(delta.abs) coalesce = if column_type == :money Arel::Nodes::NamedFunction.new( 'COALESCE', [Arel::Nodes::NamedFunction.new('CAST', [arel_column.as('NUMERIC')]), 0] ) else Arel::Nodes::NamedFunction.new('COALESCE', [arel_column, 0]) end if delta > 0 Arel::Nodes::Addition.new(coalesce, arel_delta) else Arel::Nodes::Subtraction.new(coalesce, arel_delta) end end |
Instance Method Details
#attribute_changed?(obj, attr) ⇒ Boolean
302 303 304 305 306 307 308 |
# File 'lib/counter_culture/counter.rb', line 302 def attribute_changed?(obj, attr) if ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") obj.saved_changes[attr].present? else obj.send(:attribute_changed?, attr) end end |
#change_counter_cache(obj, options) ⇒ Object
increments or decrements a counter cache
options:
:increment => true to increment, false to decrement
:relation => which relation to increment the count on,
:counter_cache_name => the column name of the counter cache
:counter_column => overrides :counter_cache_name
:delta_column => override the default count delta (1) with the value of this column in the counted record
:was => whether to get the current value or the old value of the
first part of the relation
:with_papertrail => update the column via Papertrail touch_with_version method
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/counter_culture/counter.rb', line 46 def change_counter_cache(obj, ) change_counter_column = .fetch(:counter_column) { counter_cache_name_for(obj) } # default to the current foreign key value id_to_change = foreign_key_value(obj, relation, [:was]) # allow overwriting of foreign key value by the caller id_to_change = foreign_key_values.call(id_to_change) if foreign_key_values if id_to_change && change_counter_column delta_magnitude = if delta_column ([:was] ? attribute_was(obj, delta_column) : obj.public_send(delta_column)) || 0 else counter_delta_magnitude_for(obj) end return if delta_magnitude.zero? klass = relation_klass(relation, source: obj, was: [:was]) column_type = klass.type_for_attribute(change_counter_column).type # Calculate signed delta for both paths signed_delta = [:increment] ? delta_magnitude : -delta_magnitude if Thread.current[:aggregate_counter_updates] # Store structured data for later Arel assembly remember_counter_update(klass, id_to_change, change_counter_column, signed_delta, column_type) else # Build Arel expression for non-aggregate case counter_expr = Counter.build_arel_counter_expr(klass, change_counter_column, signed_delta, column_type) arel_updates = { change_counter_column => counter_expr } end # and this will update the timestamp, if so desired if touch current_time = klass.send(:current_time_from_proper_timezone) = klass.send(:timestamp_attributes_for_update_in_model) if touch != true # starting in Rails 6 this is frozen = .dup << touch end .each do || if Thread.current[:aggregate_counter_updates] (klass, id_to_change, ) else arel_updates[] = current_time end end end primary_key = relation_primary_key(relation, source: obj, was: [:was]) if foreign_key_values && id_to_change.is_a?(Enumerable) && id_to_change.count > 1 && Array.wrap(primary_key).count == 1 # To keep this feature backwards compatible, we need to accommodate a case where # `foreign_key_values` returns an array, but we're _not_ using composite primary # keys. In that case, we need to wrap the `id_to_change` in one more array to keep # it compatible with the `.zip` call in `primary_key_conditions`. id_to_change = [id_to_change] end if Thread.current[:aggregate_counter_updates] Thread.current[:primary_key_map][klass] ||= primary_key end if @with_papertrail conditions = primary_key_conditions(primary_key, id_to_change) instance = klass.where(conditions).first if instance if instance.paper_trail.respond_to?(:save_with_version) # touch_with_version is deprecated starting in PaperTrail 9.0.0 current_time = obj.send(:current_time_from_proper_timezone) = obj.send(:timestamp_attributes_for_update_in_model) .each do || instance.send("#{}=", current_time) end execute_now_or_after_commit(obj) do instance.paper_trail.save_with_version(validate: false) end else execute_now_or_after_commit(obj) do instance.paper_trail.touch_with_version end end end end unless Thread.current[:aggregate_counter_updates] execute_now_or_after_commit(obj) do conditions = primary_key_conditions(primary_key, id_to_change) # Use Arel-based updates which let Rails handle column qualification properly, # avoiding ambiguous column errors in Rails 8.1+ UPDATE...FROM syntax. klass.where(conditions).distinct(false).update_all(arel_updates) # Determine if we should update the in-memory counter on the associated object. # When updating the old counter (was: true), we need to carefully consider two scenarios: # 1) The belongs_to relation changed (e.g., moving a child from parent A to parent B): # In this case, obj.association now points to parent B, but we're decrementing parent A's # counter. We should NOT update the in-memory counter because it would incorrectly # modify parent B's cached value. # 2) A conditional counter's condition changed (e.g., condition: true → false): # In this case, obj.association still points to the same parent, but the counter column # name changed (e.g., from 'conditional_count' to nil). We SHOULD update the in-memory # counter so the parent object reflects the decremented value without requiring a reload. # We distinguish these cases by comparing foreign keys: if the current and previous foreign # keys are identical, we're in scenario 2 and should update the in-memory counter. should_update_counter = if [:was] current_fk = foreign_key_value(obj, relation, false) previous_fk = foreign_key_value(obj, relation, true) current_fk == previous_fk && current_fk.present? else true end if should_update_counter operator = [:increment] ? '+' : '-' assign_to_associated_object(obj, relation, change_counter_column, operator, delta_magnitude) end end end end end |
#counter_cache_name_for(obj) ⇒ Object
Gets the name of the counter cache for a specific object
obj: object to calculate the counter cache name for cache_name_finder: object used to calculate the cache name
186 187 188 189 190 191 192 193 194 195 |
# File 'lib/counter_culture/counter.rb', line 186 def counter_cache_name_for(obj) # figure out what the column name is if counter_cache_name.is_a?(Proc) # dynamic column name -- call the Proc counter_cache_name.call(obj) else # static column name counter_cache_name end end |
#counter_delta_magnitude_for(obj) ⇒ Object
Gets the delta magnitude of the counter cache for a specific object
obj: object to calculate the counter cache name for
174 175 176 177 178 179 180 |
# File 'lib/counter_culture/counter.rb', line 174 def counter_delta_magnitude_for(obj) if delta_magnitude.is_a?(Proc) delta_magnitude.call(obj) else delta_magnitude end end |
#execute_now_or_after_commit(obj, &block) ⇒ Object
382 383 384 385 386 387 388 389 390 |
# File 'lib/counter_culture/counter.rb', line 382 def execute_now_or_after_commit(obj, &block) execute_after_commit = @execute_after_commit.is_a?(Proc) ? @execute_after_commit.call : @execute_after_commit if execute_after_commit obj.execute_after_commit(&block) else block.call end end |
#first_level_relation_changed?(instance) ⇒ Boolean
294 295 296 297 298 299 300 |
# File 'lib/counter_culture/counter.rb', line 294 def first_level_relation_changed?(instance) return true if attribute_changed?(instance, first_level_relation_foreign_key) if polymorphic? return true if attribute_changed?(instance, first_level_relation_foreign_type) end false end |
#first_level_relation_foreign_key ⇒ Object
gets the foreign key name of the relation. will look at the first level only – i.e., if passed an array will consider only its first element
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
355 356 357 358 |
# File 'lib/counter_culture/counter.rb', line 355 def first_level_relation_foreign_key first_relation = relation.first if relation.is_a?(Enumerable) relation_reflect(first_relation).foreign_key end |
#first_level_relation_foreign_type ⇒ Object
360 361 362 363 364 |
# File 'lib/counter_culture/counter.rb', line 360 def first_level_relation_foreign_type return nil unless polymorphic? first_relation = relation.first if relation.is_a?(Enumerable) relation_reflect(first_relation).foreign_type end |
#foreign_key_value(obj, relation, was = false) ⇒ Object
gets the value of the foreign key on the given relation
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
was: whether to get the current or past value from ActiveRecord;
pass true to get the past value, false or nothing to get the
current value
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/counter_culture/counter.rb', line 209 def foreign_key_value(obj, relation, was = false) original_relation = relation relation = relation.is_a?(Enumerable) ? relation.dup : [relation] value = if was first = relation.shift foreign_key_value = attribute_was(obj, relation_foreign_key(first)) klass = relation_klass(first, source: obj, was: was) if foreign_key_value primary_key = relation_primary_key(first, source: obj, was: was) conditions = primary_key_conditions(primary_key, foreign_key_value) klass.where(conditions).first end else obj end while !value.nil? && relation.size > 0 value = value.send(relation.shift) end primary_key = relation_primary_key(original_relation, source: obj, was: was) Array.wrap(primary_key).map { |pk| value.try(pk&.to_sym) }.compact.presence end |
#full_primary_key(klass) ⇒ Object
the string to pass to order() in order to sort by primary key
198 199 200 |
# File 'lib/counter_culture/counter.rb', line 198 def full_primary_key(klass) Array.wrap(klass.quoted_primary_key).map { |pk| "#{klass.quoted_table_name}.#{pk}" }.join(', ') end |
#polymorphic? ⇒ Boolean
310 311 312 313 314 315 316 |
# File 'lib/counter_culture/counter.rb', line 310 def polymorphic? is_polymorphic = relation_reflect(relation)..key?(:polymorphic) if is_polymorphic && !(relation.is_a?(Symbol) || relation.length == 1) raise "Polymorphic associations only supported with one level" end return is_polymorphic end |
#previous_model(obj) ⇒ Object
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/counter_culture/counter.rb', line 366 def previous_model(obj) prev = obj.dup changes_method = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? :saved_changes : :changed_attributes obj.public_send(changes_method).each do |key, value| next unless obj.has_attribute?(key.to_s) old_value = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? value.first : value # We set old values straight to AR @attributes variable to avoid # write_attribute callbacks from other gems (e.g. ArTransactionChanges) prev.instance_variable_get(:@attributes).write_from_user(key, old_value) end prev end |
#relation_foreign_key(relation) ⇒ Object
gets the foreign key name of the given relation
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
322 323 324 |
# File 'lib/counter_culture/counter.rb', line 322 def relation_foreign_key(relation) relation_reflect(relation).foreign_key end |
#relation_klass(relation, source: nil, was: false) ⇒ Object
gets the class of the given relation
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
source [optional]: the source object,
only needed for polymorphic associations,
probably only works with a single relation (symbol, or array of 1 symbol)
was: boolean
we're actually looking for the old value -- only can change for polymorphic relations
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/counter_culture/counter.rb', line 276 def relation_klass(relation, source: nil, was: false) reflect = relation_reflect(relation) if reflect..key?(:polymorphic) raise "Can't work out relation's class without being passed object (relation: #{relation}, reflect: #{reflect})" if source.nil? raise "Can't work out polymorhpic relation's class with multiple relations yet" unless (relation.is_a?(Symbol) || relation.length == 1) # this is the column that stores the polymorphic type, aka the class name type_column = reflect.foreign_type.to_sym # so now turn that into the class that we're looking for here if was attribute_was(source, type_column).try(:constantize) else source.public_send(type_column).try(:constantize) end else reflect.klass end end |
#relation_primary_key(relation, source: nil, was: false) ⇒ Object
gets the primary key name of the given relation
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
source: the model instance that the relationship is linked from,
only needed for polymorphic associations,
probably only works with a single relation (symbol, or array of 1 symbol)
was: boolean
we're actually looking for the old value -- only can change for polymorphic relations
335 336 337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/counter_culture/counter.rb', line 335 def relation_primary_key(relation, source: nil, was: false) reflect = relation_reflect(relation) klass = nil if reflect..key?(:polymorphic) raise "can't handle multiple keys with polymorphic associations" unless (relation.is_a?(Symbol) || relation.length == 1) raise "must specify source for polymorphic associations..." unless source return reflect.[:primary_key] if reflect..key?(:primary_key) return relation_klass(relation, source: source, was: was).try(:primary_key) end reflect.association_primary_key(klass) end |
#relation_reflect(relation) ⇒ Object
gets the reflect object on the given relation
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
263 264 265 |
# File 'lib/counter_culture/counter.rb', line 263 def relation_reflect(relation) relation_reflect_and_model(relation).first end |
#relation_reflect_and_model(relation) ⇒ Object
gets the reflect object on the given relation and the model that defines this reflect
relation: a symbol or array of symbols; specifies the relation
that has the counter cache column
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/counter_culture/counter.rb', line 237 def relation_reflect_and_model(relation) relation = relation.is_a?(Enumerable) ? relation.dup : [relation] # go from one relation to the next until we hit the last reflect object klass = model while relation.size > 0 cur_relation = relation.shift reflect = klass.reflect_on_association(cur_relation) raise "No relation #{cur_relation} on #{klass.name}" if reflect.nil? if relation.size > 0 # not necessary to do this at the last link because we won't use # klass again. not calling this avoids the following causing an # exception in the now-supported one-level polymorphic counter cache klass = reflect.klass end end return [reflect, klass] end |