Skip to content

Instantly share code, notes, and snippets.

@benkimball
Last active March 22, 2018 23:53
Show Gist options
  • Save benkimball/f80c3bcdd79d2783e45fc9008f25277e to your computer and use it in GitHub Desktop.
Save benkimball/f80c3bcdd79d2783e45fc9008f25277e to your computer and use it in GitHub Desktop.
This is a (non-original) concept for a utility superclass for performing production data changes that defaults to a "dry_run" mode (though it relies on the subclass implementer to ensure safety).
# frozen_string_literal: true
# Looks for a keyword argument `dry_run`. If it is explicitly set to false,
# then run the actual data change. In all other cases, perform a dry run.
# Both #perform_real and #perform_dry_run are expected to be supplied by
# the subclass.
#
# Use a subclass of ProductionDataChange in this way:
# @example
# ExampleDataChange.new.perform(...)
# ExampleDataChange.new(dry_run: false).perform(...)
class ProductionDataChange
attr_reader :dry_run, :io
# A PDC has an initializer that sets @dry_run to true unless explicitly
# set to false. This is for safety when testing the code. Don't do
# dangerous things in your `perform_dry_run` method, only in your
# `perform_real` method.
def initialize(dry_run: true, io: $stdout)
@dry_run = dry_run
@io = io
end
# This method signature is intentionally broad to support many potential
# signatures in the subclass. Omits &block though because it seemed unlikely
# to be needed.
def perform(*args, **kwargs)
m = method(dry_run ? :perform_dry_run : :perform_real)
if m.arity == 0
m.call
else
m.call(*args, **kwargs)
end
end
protected
def perform_real
raise NotImplementedError, 'implement #perform_real in subclass'
end
def perform_dry_run
raise NotImplementedError, 'implement #perform_dry_run in subclass'
end
end
# Here's an example of use, a PDC that selects certain "rows" from the "database"
# and changes the name of each.
class ExampleDataChange < ProductionDataChange
# This method is called in the default case, when the user only wants to
# preview their changes and not modify the database
def perform_dry_run
rows = find_affected_rows
each_with_reporting(rows, message: 'not processed - dry run') do |item|
io.print item[:name]
end
end
# This method is called when the user wants to make changes to actual
# prod data; it should have the same signature as `perform_dry_run`
def perform_real
rows = find_affected_rows
each_with_reporting(rows) do |item|
io.print item[:name]
io.print ' -> '
mutate(item)
io.print item[:name]
end
end
# You can extract common functionality into easily-testable methods
# ...such as how you find records to change
def find_affected_rows
database.select { |row| row[:dad] == 'Adam' }
end
# ...or how you change them
def mutate(item)
item[:name] = item[:name].downcase.reverse.capitalize
end
private
# You can extract code that both perform_* methods need
def each_with_reporting(collection, message: 'processed', &block)
io.puts "Found #{collection.length} item(s) to change"
collection.each_with_index do |item, index|
io.print "Item #{index + 1}. "
block.call(item)
io.puts " #{message}"
end
end
def database
[
{ name: 'Abel', dad: 'Adam' },
{ name: 'Cain', dad: 'Adam' },
{ name: 'Seth', dad: 'Adam' },
{ name: 'Enoch', dad: 'Cain' }
]
end
end
# You can test the data change
require 'rspec'
RSpec.describe ExampleDataChange do
describe '#find_affected_rows' do
it 'should only find children of Adam' do
rows = subject.find_affected_rows
expect(rows.map { |row| row[:dad] }).to all eq('Adam')
end
it 'should find 3 people' do
rows = subject.find_affected_rows
expect(rows.length).to eq 3
end
end
describe '#mutate' do
let(:item) { { name: 'Enos', dad: 'Seth' } }
it 'should reverse the item name' do
subject.mutate(item)
expect(item).to include(name: 'Sone')
end
it 'should not change the item parentage' do
subject.mutate(item)
expect(item).to include(dad: 'Seth')
end
end
end
# ProductionDataChange lets you inject an IO instance, so you can test
# that part of your class, too
RSpec.describe ExampleDataChange do
let!(:io) { StringIO.new }
subject { ExampleDataChange.new(io: io) }
it 'looks right' do
subject.perform
expect(io.string).to eq <<~OUTPUT
Found 3 item(s) to change
Item 1. Abel not processed - dry run
Item 2. Cain not processed - dry run
Item 3. Seth not processed - dry run
OUTPUT
end
end
@benkimball
Copy link
Author

One thing I don't like about this implementation is that it's annoying to implement an initialize method in a subclass, because you need to be aware of the dry_run and io kwargs.

@benkimball
Copy link
Author

Another issue is "how do I get my code onto the server instance," which this does nothing to address.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment