From 83345a6a4a0cc776f15fcc541cfc80a31b713644 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 19:54:03 +0200 Subject: [PATCH] feat(orm): extend associable with define_touch and validate_dependent! --- lib/prism/orm/associable.rb | 204 ++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/lib/prism/orm/associable.rb b/lib/prism/orm/associable.rb index 6ab752d..c42f6c9 100644 --- a/lib/prism/orm/associable.rb +++ b/lib/prism/orm/associable.rb @@ -285,6 +285,210 @@ module Prism # Either half of that pair can in principle change independently of # the other on update, so old/new are resolved separately for each. def define_counter_cache(target_class_name, foreign_key, counter_cache, type_key: nil) + after_create do + owner_id = self[foreign_key] + owner_type = type_key ? self[type_key] : target_class_name + next unless owner_id && owner_type + column = Associable.counter_cache_column(self.class, counter_cache) + Object.const_get(owner_type).increment_counter(column, owner_id) + end + + after_destroy do + owner_id = self[foreign_key] + owner_type = type_key ? self[type_key] : target_class_name + next unless owner_id && owner_type + column = Associable.counter_cache_column(self.class, counter_cache) + Object.const_get(owner_type).decrement_counter(column, owner_id) + end + + # A restore (Paranoia#restore) never goes through after_create - + # it un-stamps and updates an existing row - so without this a + # counter_cache'd child coming back from the trash would leave + # its parent's count permanently short by one. + after_restore do + owner_id = self[foreign_key] + owner_type = type_key ? self[type_key] : target_class_name + next unless owner_id && owner_type + column = Associable.counter_cache_column(self.class, counter_cache) + Object.const_get(owner_type).increment_counter(column, owner_id) + end + + after_update do + fk_changed = saved_change_to_attribute?(foreign_key) + type_changed = type_key && saved_change_to_attribute?(type_key) + next unless fk_changed || type_changed + + unchanged_id = [self[foreign_key], self[foreign_key]] + old_id, new_id = fk_changed ? saved_change_to_attribute(foreign_key) : unchanged_id + current_type = type_key ? self[type_key] : target_class_name + unchanged_type = [current_type, current_type] + old_type, new_type = type_changed ? saved_change_to_attribute(type_key) : unchanged_type + + column = Associable.counter_cache_column(self.class, counter_cache) + Object.const_get(old_type).decrement_counter(column, old_id) if old_id && old_type + Object.const_get(new_type).increment_counter(column, new_id) if new_id && new_type + end + end + + # belongs_to ..., touch: true - Model#touch's the owner (updated_at, + # plus `touch: :column` for an explicit extra one) whenever this + # child is saved or destroyed. Unlike counter_cache this fires on + # EVERY save, not only when the foreign key itself changes - editing + # a comment's body with no change to its post_id still needs to bust + # the post's #cache_key. If the foreign key did change, the OLD + # owner is touched too (mirrors Rails: both sides of a reassignment + # see their cache invalidated), same before/after split as + # counter_cache's after_update above. A parent that no longer + # exists (already destroyed elsewhere) is left alone rather than + # raising - the child's own save/destroy already succeeded and + # should not fail retroactively over stale cache bookkeeping. + # + # after_destroy skips the touch entirely when + # destroyed_by_association_foreign_key names this same foreign key + # - i.e. when this child is being destroyed because ITS OWNER is + # cascading `has_many ..., dependent: :destroy` into it (see + # apply_dependent above). Without this guard, a paranoid, + # optimistically-locked owner running that cascade would have each + # cascaded child's after_destroy re-`find` the owner and bump its + # lock_version - so by the time the owner's own soft/hard delete + # finally writes using the lock_version it captured once at the top + # of its #destroy, that captured value no longer matches what the + # cascade already advanced it to, and the owner's own delete raises + # StaleObjectError over a write it never asked for. An owner that's + # disappearing in this same operation was never going to have + # anyone observe the bumped-then-discarded timestamp anyway - only + # the exception would have been visible. Mirrors Rails' + # ActiveRecord::Associations `destroyed_by_association` guard on + # its own touch callback, for exactly this reason. + # + # type_key: is set by define_belongs_to_polymorphic - which class to + # Object.const_get and #find on varies per row (read off that + # column) instead of being the single fixed target_class_name a + # plain belongs_to always touches, so id and type travel together as + # a pair everywhere below, same as in define_counter_cache above. + def define_touch(target_class_name, foreign_key, touch, type_key: nil) + extra_column = touch == true ? nil : touch.to_s + perform_touch = lambda do |owner_type, owner_id| + next unless owner_id && owner_type + begin + record = Object.const_get(owner_type).find(owner_id) + extra_column ? record.touch(extra_column) : record.touch + rescue RecordNotFound, StaleObjectError + nil + end + end + + after_save do + current_type = type_key ? self[type_key] : target_class_name + perform_touch.call(current_type, self[foreign_key]) + + fk_changed = saved_change_to_attribute?(foreign_key) + type_changed = type_key && saved_change_to_attribute?(type_key) + if fk_changed || type_changed + old_id = fk_changed ? saved_change_to_attribute(foreign_key)[0] : self[foreign_key] + old_type = type_changed ? saved_change_to_attribute(type_key)[0] : current_type + perform_touch.call(old_type, old_id) + end + end + + after_destroy do + unless destroyed_by_association_foreign_key == foreign_key + owner_type = type_key ? self[type_key] : target_class_name + perform_touch.call(owner_type, self[foreign_key]) + end + end + end + + def validate_dependent!(dependent) + return if DEPENDENT_OPTIONS.include?(dependent) + raise ArgumentError, "unsupported dependent: #{dependent.inspect} (use :destroy or :nullify)" + end + + def habtm_join_table_name(a, b) + [Inflector.tableize(a), Inflector.tableize(b)].sort.join('_') + end + end + + def association_cache + @association_cache ||= {} + end + + # Set on a child by apply_dependent right before it destroys that + # child as part of its owner's own dependent: :destroy cascade, + # naming the foreign key that made it a dependent. belongs_to ..., + # touch: true's after_destroy hook (see define_touch) checks this to + # skip touching an owner that is disappearing in this very same + # operation - not just to dodge a redundant write, but because the + # owner's own destroy already captured its lock_version before the + # cascade started, and cascade-triggered touches bumping it further + # would make that captured version stale by the time the owner's own + # soft/hard delete finally writes (see the long comment on + # define_touch). Mirrors Rails' own ActiveRecord::Associations + # `destroyed_by_association` for exactly this reason. + attr_accessor :destroyed_by_association_foreign_key + + private + + def cascade_dependent_associations + self.class.dependent_associations.each do |name, meta| + cascade_one_association(meta) + end + end + + def cascade_one_association(meta) + target = Object.const_get(meta[:class_name]) + condition = { meta[:foreign_key] => id } + condition[meta[:type_key]] = self.class.name if meta[:type_key] + case meta[:type] + when :has_many + apply_dependent(target.where(condition).to_a, meta) + when :has_one + record = target.find_by(condition) + apply_dependent(record ? [record] : [], meta) + end + end + + def apply_dependent(records, meta) + case meta[:dependent] + when :destroy + records.each do |record| + record.destroyed_by_association_foreign_key = meta[:foreign_key] + record.destroy + end + when :nullify + updates = { meta[:foreign_key] => nil } + updates[meta[:type_key]] = nil if meta[:type_key] + records.each { |record| record.update(updates) } + end + end + + # Mirror of cascade_dependent_associations for Paranoia#restore(recursive: + # true): only :destroy associations can have anything to bring back + # (:nullify never removed a row, it just detached one), and only a + # paranoid target could possibly have a soft-deleted row sitting in + # only_deleted - a plain model's dependent: :destroy children were + # already removed for real when the parent was trashed, so there is + # nothing left to restore. Recurses into each restored child with + # recursive: true itself, so restoring a Post brings its Comments back + # AND each Comment's own paranoid dependents (its Likes), if any. + def cascade_restore_dependent_associations + self.class.dependent_associations.each do |name, meta| + next unless meta[:dependent] == :destroy + restore_one_dependent_association(meta) + end + end + + def restore_one_dependent_association(meta) + target = Object.const_get(meta[:class_name]) + return unless target.respond_to?(:paranoid?) && target.paranoid? + condition = { meta[:foreign_key] => id } + condition[meta[:type_key]] = self.class.name if meta[:type_key] + case meta[:type] + when :has_many + target.only_deleted.where(condition).to_a.each { |record| record.restore(recursive: true) } + when :has_one + record = target.only_deleted.find_by(condition) + record.restore(recursive: true) if record end end end