Last active
March 22, 2018 23:53
-
-
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).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
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
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 thedry_run
andio
kwargs.