Stowaway: Deleting Records in your Rails app, with a concept
To practice Domain Modeling, I like to play with anything that sparks an idea.
When I read Soft Deletion Probably isn't Worth It that's exactly what happened.
For now, you can skip most of the post and just read this deletion idea that we'll translate to Rails. — the rest of the post is well worth a read though!
Let's start translating.
A tentative name for deletions: Trashmonger
Trashmonger
was the word that popped into my mind after I'd read the post initially.
An evocative name like that is usually enough for me to know there's something I gotta explore further.
Why that name? I'm not sure, but I've learned to trust those oddball names. — more creativity is down that road.
Revising the deleted_record
schema
Here's the posts' suggested general schema:
CREATE TABLE deleted_record (
id uuid PRIMARY KEY DEFAULT gen_ulid(),
deleted_at timestamptz NOT NULL default now(),
original_table varchar(200) NOT NULL,
original_id uuid NOT NULL,
data jsonb NOT NULL
);
But in Rails, we can do better:
deleted_at
can becreated_at
and Rails handles it- The
id
can just go intodata
original_table
feels non-Rails, so let's go withrecord_type
data
can berecord_attributes
The actual process was me diving into Ruby code first, but that was over 6 months ago. I think I knew what names I wanted pretty quickly.
Before long, I had something like this:
class Trashmonger::Record < ActiveRecord::Base
def self.preserve(record)
create! record_type: record.class, record_attributes: record.attributes
end
def record = record_type.constantize.then do
_1.new record_attributes.slice(*_1.attribute_names)
end
def recreate! = transaction do
record.save!
destroy!
end
end
class ApplicationRecord < ActiveRecord::Base
has_trashmongers # Would do this:
before_destroy { trashmongers.preserve(self) }
def self.trashmongers = Trashmonger::Record.where(record_type: name)
end
Future-proofing:
record_attributes.slice(*_1.attribute_names)
strips any columns dropped since destroy.
Renaming to Stowaway: a better, clearer concept
Trashmonger
, while it helped set me off, now stuck out like a sore thumb.
Something about it just didn't sit well.
I kept noodling on names and eventually Stowaway
popped up.
It fit perfectly!
It's exactly what we're doing when we're deleting a record: we're stowing it away in case we need it later.
A name like that not only helps clarify our intent now, but it's also a whole concept around deletions:
- With a concept, versus just an abstraction like
deleted_record
, we can frame our thinking. - With a concept, our cognitive load can be decreased.
Stowaway::Record
is now what once was Trashmonger::Record
.
But wait,
::Record
still clashes with therecord*
methods.
Ah proxying! That's what we're really doing.
Stowaway::Proxy
is now what Stowaway::Record
just was.
Naming is hard! Renaming early is invaluable leverage.
Update: Speaking of renaming, reader Andy Croll sent in this great suggestion:
Love this. How about “Relics” for the name? You keep them about because you believe they're precious, but turns out it might just be “old stuff you don’t need”. :-)
Peep the remaining renames here:
# app/models/stowaway.rb
module Stowaway
def self.table_name_prefix = "stowaway_"
end
# app/models/stowaway/proxy.rb
class Stowaway::Proxy < ActiveRecord::Base
def self.stow(record)
create! record_type: record.class, record_attributes: record.attributes
end
def recreate! = transaction do
record.save!
destroy!
end
def record = record_class.constantize.then do
_1.new(record_attributes.slice(*_1.attribute_names))
end
end
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
def self.has_stowaways
before_destroy { self.class.stowaways.stow(self) }
define_singleton_method(:stowaways) { Stowaway::Proxy.where(record_type: name) }
end
end
It's rounded out a little more, but let's go further.
Rounding out Stowaway
With clearer names, let's really round this out.
If I'm running this in a console, I want to see any discarded attributes. It might be nice to see just the stowed attributes too.
Let's add these:
def stowed_attributes = record_attributes.slice(*record_class.attribute_names)
def discarded_attributes = record_attributes.except(*record_class.attribute_names)
def record_class = record_type.constantize
Now record
can be:
def record
record_class.new(stowed_attributes)
end
Seeing Stowaway run
This is a secret trick, that not a lot of people know about.
See this Rails bug report template.
In one file, you can: connect to a SQLite memory database, define your schema and play around with your models.
Let's adapt this for Stowaway, and now you can run this on your machine:
require "bundler/inline"
gemfile do
gem "activerecord", require: "active_record"
gem "sqlite3", "< 2.0" # Rails 7.1 doesn't support 2.0+
end
ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :stowaway_proxies do |t|
t.string :record_type, null: false, index: true
t.json :record_attributes, null: false, default: {} # Use t.jsonb on Postgres
t.timestamps
end
create_table :posts do |t|
t.string :title, null: false
t.timestamps
end
end
module Stowaway
def self.table_name_prefix = "stowaway_"
end
class Stowaway::Proxy < ActiveRecord::Base
def self.stow(record)
create! record_type: record.class, record_attributes: record.attributes
end
def stowed_attributes = record_attributes.slice(*record_class.attribute_names)
def discarded_attributes = record_attributes.except(*record_class.attribute_names)
def recreate! = transaction do
destroy!
record.tap(&:save!)
end
def record
record_class.new(stowed_attributes)
end
def record_class = record_type.constantize
end
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
def self.has_stowaways
before_destroy { self.class.stowaways.stow(self) }
define_singleton_method(:stowaways) { Stowaway::Proxy.where(record_type: name) }
end
end
class Post < ApplicationRecord
has_stowaways
end
first = Post.create!(title: "First").tap(&:destroy!)
first_stowaway = Post.stowaways.first # Try running first_stowaway.recreate!; notice created_at/updated_at is the original values.
second = Post.create!(title: "Second").tap(&:destroy!)
# We're simulating that since the second Post was stowed the `admin` column was removed.
second_stowaway = Post.stowaways.last
second_stowaway.record_attributes.merge!("admin" => true)
second_stowaway.save! # Try running second_stowaway.recreate!
binding.irb # Fire up a console, and play around with all the methods!
Notice
recreate!
was updated: from playing around in the console I learned it should return the recreated record.
Summary
- We read a blog post about deleting records.
- We adapted it to our Rails context.
- We kept revising and renaming to make our concept clearer.
- You can do all this in your apps too.
Feel free to copy over Stowaway and use it in your app. I'd be really curious to hear about it if you do!