feat(orm): extend associable with define_touch and validate_dependent!
This commit is contained in:
1 parent
de3f18375c
commit
83345a6a4a
1 file changed
+204
@@ -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
|
||||
|
||||
Reference in new issue
Block a user