feat(orm): extend model with destroy and touch

This commit is contained in:
dualfroz committed 2026-09-08 15:15:00 +02:00
1 parent 13b4613023
commit fc91e95b62
1 file changed
+103 -5
+103 -5
View File
@@ -319,9 +319,77 @@ module Prism
save! save!
end end
def destroy # Cascaded dependent destroys run inside the same transaction as the
self.class.store.delete(self.class.table_name, id) if persisted? # parent's own delete, so a failure partway through the cascade (or
@destroyed = true # in after_destroy) rolls back every row it already removed - not
# just the parent - instead of leaving orphaned deletes behind.
# `force: true` (from Paranoia#really_destroy!) always removes the row
# for real even on a paranoid model, running the full callback/cascade/
# locking sequence again even if the record was already soft-deleted -
# matches Rails paranoia's really_destroy!, which re-runs the destroy
# callbacks rather than assuming a soft-deleted record is untouchable.
def destroy(force: false)
return self if destroyed? && !force
# Set before before_destroy runs (not after the paranoid-delete
# branch below decides) so a before_destroy hook can tell #soft_deleting?
# apart from a real delete - e.g. a hook that removes an uploaded
# file from disk should only do that on a hard delete, not one a
# paranoid model will still let #restore bring back.
@destroy_force = force
if !new_record?
self.class.transaction do
enlist_in_transaction(:destroy)
return self unless run_callbacks(:before_destroy)
cascade_dependent_associations
if paranoid_delete? && !force
soft_delete_row
elsif self.class.locking_enabled?
begin
self.class.store.delete(self.class.table_name, id, [self.class.locking_column, lock_version.to_i])
rescue Store::StaleRowError
raise StaleObjectError.new(self, :destroy)
end
else
self.class.store.delete(self.class.table_name, id)
end
@destroyed = true
run_callbacks(:after_destroy)
end
else
@destroyed = true
end
self
end
# ActiveRecord::Persistence#touch-lite: bumps updated_at (plus any
# named extra attributes, e.g. `post.touch(:published_at)`) straight
# on the stored row and skips validations and the save callback chain
# entirely - only after_touch runs. This is what belongs_to ...,
# touch: true rides on (see Associable#define_touch): touching a
# comment's post whenever the comment is saved or destroyed changes
# the post's #cache_key, which is exactly what busts a `cache post do
# ... end` fragment that embeds the comment list.
def touch(*names)
raise RecordNotFound, "cannot touch a new record" if new_record?
raise RecordNotFound, "cannot touch a destroyed record" if destroyed?
now = Time.now.utc
attributes = {}
attributes['updated_at'] = now if self.class.columns.include?('updated_at')
names.each { |name| attributes[name.to_s] = now }
return self if attributes.empty?
if self.class.locking_enabled?
begin
row = self.class.store.update(self.class.table_name, id, attributes,
[self.class.locking_column, lock_version.to_i])
rescue Store::StaleRowError
raise StaleObjectError.new(self, :touch)
end
attributes[self.class.locking_column] = row[self.class.locking_column]
else
self.class.store.update(self.class.table_name, id, attributes)
end
attributes.each { |key, value| @attributes[key] = value }
run_callbacks(:after_touch)
self self
end end
@@ -336,6 +404,16 @@ module Prism
@attributes.dup @attributes.dup
end end
# Fragment-cache key: changes whenever the record is touched, so a
# `cache post do ... end` view fragment (Prism::CacheHelpers) keyed on
# this automatically busts on update without anyone deleting the old
# entry by hand.
def cache_key
base = "#{self.class.table_name}/#{id}"
timestamp = self['updated_at'] || self['created_at']
timestamp ? "#{base}-#{timestamp.to_i}" : base
end
def ==(other) def ==(other)
other.is_a?(self.class) && !id.nil? && id == other.id other.is_a?(self.class) && !id.nil? && id == other.id
end end
@@ -351,14 +429,34 @@ module Prism
@attributes = row.dup @attributes = row.dup
@new_record = false @new_record = false
@destroyed = false @destroyed = false
clear_changes_information
self self
end end
def touch_timestamps def touch_timestamps
now = Time.now.utc now = Time.now.utc
columns = self.class.columns columns = self.class.columns
@attributes['created_at'] ||= now if columns.include?('created_at') && new_record? self['created_at'] = now if columns.include?('created_at') && new_record? && self['created_at'].nil?
@attributes['updated_at'] = now if columns.include?('updated_at') self['updated_at'] = now if columns.include?('updated_at')
end
def initialize_lock_version
return unless self.class.locking_enabled?
@attributes[self.class.locking_column] = 0 if self[self.class.locking_column].nil?
end
# Only the columns this instance actually changed since it was
# loaded/created, never the full @attributes hash - an UPDATE that
# sends every column (including ones this instance never touched)
# would overwrite any value another code path mutated directly on
# the stored row in the meantime, such as a counter_cache bump
# (Store#increment_counter) from a cascade_dependent_associations
# step that runs on this very record earlier in the same #destroy,
# or simply this instance's attributes being stale relative to a
# concurrent write. Matches real UPDATE semantics (only SET the
# columns that changed) instead of a full-row REPLACE.
def dirty_attributes
@attributes.slice(*changed)
end end
end end
end end