From fc91e95b62ff6c914ce8dc9a3171880fd6065003 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 8 Sep 2026 15:15:00 +0200 Subject: [PATCH] feat(orm): extend model with destroy and touch --- lib/prism/orm/model.rb | 108 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/lib/prism/orm/model.rb b/lib/prism/orm/model.rb index e94ab21..9324b11 100644 --- a/lib/prism/orm/model.rb +++ b/lib/prism/orm/model.rb @@ -319,9 +319,77 @@ module Prism save! end - def destroy - self.class.store.delete(self.class.table_name, id) if persisted? - @destroyed = true + # Cascaded dependent destroys run inside the same transaction as the + # parent's own delete, so a failure partway through the cascade (or + # 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 end @@ -336,6 +404,16 @@ module Prism @attributes.dup 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) other.is_a?(self.class) && !id.nil? && id == other.id end @@ -351,14 +429,34 @@ module Prism @attributes = row.dup @new_record = false @destroyed = false + clear_changes_information self end def touch_timestamps now = Time.now.utc columns = self.class.columns - @attributes['created_at'] ||= now if columns.include?('created_at') && new_record? - @attributes['updated_at'] = now if columns.include?('updated_at') + self['created_at'] = now if columns.include?('created_at') && new_record? && self['created_at'].nil? + 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