feat(orm): extend associable with define_touch and validate_dependent!

This commit is contained in:
dualfroz committed 2026-09-07 19:54:03 +02:00
1 parent de3f18375c
commit 83345a6a4a
1 file changed
+204
+204
View File
@@ -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