├── README.mkd ├── lib ├── snapshot.rb └── tasks │ └── snapshot.rake └── snapshot.gemspec /README.mkd: -------------------------------------------------------------------------------- 1 | Snapshot 2 | ======== 3 | 4 | The snapshot plugin adds two new rake tasks that make it easy for you to take 5 | a snapshot of your existing (development) database, and restore it again. 6 | 7 | Why would you want this? 8 | 9 | Imagine you are developing an app. You've spent a few hours filling your 10 | development DB with data so that you can design a particular UI scenario. 11 | Now, though, you need to design another scenario, which requires a different 12 | dataset, and you are loathe to lose the data you so laboriously entered. 13 | 14 | The snapshot plugin saves the day: 15 | 16 | $ rake db:snapshot 17 | 18 | This creates a db/snapshot file (which records all the data in your DB), 19 | and a db/snapshot.schema file (which records the state of the schema when 20 | the snapshot was taken). At any time, then, you can restore that snapshot: 21 | 22 | $ rake db:snapshot:restore 23 | 24 | This will erase the existing DB, restore the db/snapshot.schema schema, 25 | and then load the data. If there are any pending migrations, it will then 26 | run those, and then regenerate the snapshot so that it stays at the latest 27 | schema. 28 | 29 | You can pass a different snapshot file to use as a parameter, to either 30 | task: 31 | 32 | $ rake db:snapshot[scenarios/real-estate] 33 | ... 34 | $ rake db:snapshot:restore[scenarios/telemarketer] 35 | 36 | 37 | Limitations 38 | ----------- 39 | 40 | The current version will probably fail when there are foreign key constraints, 41 | since the order the tables and rows are restored is not guaranteed to be in 42 | any particular order. 43 | 44 | Also, even moderately large data sets (e.g. multiple thousands of rows) may 45 | result in poor performance during snapshot and restore, since the data is all 46 | loaded into memory. 47 | -------------------------------------------------------------------------------- /lib/snapshot.rb: -------------------------------------------------------------------------------- 1 | module Snapshot 2 | class Engine < ::Rails::Engine 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/tasks/snapshot.rake: -------------------------------------------------------------------------------- 1 | class SnapshotHelper 2 | def initialize 3 | @on_snapshot = [] 4 | @on_restore = [] 5 | end 6 | 7 | def on_snapshot(&block) 8 | @on_snapshot.push block 9 | end 10 | 11 | def on_restore(&block) 12 | @on_restore.push block 13 | end 14 | 15 | def snapshot!(path) 16 | @on_snapshot.each do |callback| 17 | callback.call(path) 18 | end 19 | end 20 | 21 | def restore!(path) 22 | @on_restore.each do |callback| 23 | callback.call(path) 24 | end 25 | end 26 | end 27 | 28 | DBSnapshot = SnapshotHelper.new 29 | 30 | namespace :db do 31 | desc < "db:schema:dump" do |t, args| 39 | to = args[:to] || "db/snapshot" 40 | 41 | cp "db/schema.rb", to + ".schema" 42 | conn = ActiveRecord::Base.connection 43 | 44 | meta = { 45 | :now => Time.now.utc, 46 | :tables => (data = []) } 47 | 48 | conn.tables.each do |table| 49 | next if table == "schema_migrations" 50 | 51 | size = conn.select_value("SELECT count(*) FROM #{conn.quote_table_name(table)}").to_i 52 | next if size.zero? 53 | 54 | STDERR.puts "- #{table} (#{size} rows)..." 55 | data << { 56 | :table => table, 57 | :rows => conn.select_all("SELECT * FROM #{conn.quote_table_name(table)}") 58 | } 59 | end 60 | 61 | STDERR.puts "writing snapshot to #{to}..." 62 | File.open(to, "w") { |out| YAML.dump(meta, out) } 63 | 64 | DBSnapshot.snapshot!(to) 65 | end 66 | 67 | namespace :snapshot do 68 | desc < :environment do |t, args| 83 | abort "refusing to restore snapshot in production" if Rails.env.production? 84 | 85 | from = args[:from] || "db/snapshot" 86 | abort "snapshot file does not exist (#{from})" unless File.exists?(from) 87 | 88 | STDOUT.sync = true # we want to emit output as we generate it, immediately 89 | conn = ActiveRecord::Base.connection 90 | 91 | # If the database is not empty, prompt for confirmation before destroying the 92 | # existing data. 93 | conn.tables.each do |table| 94 | next if table == "schema_migrations" 95 | if conn.select_value("SELECT count(*) FROM #{conn.quote_table_name(table)}").to_i > 0 96 | puts "This database is not empty. Are you sure you want to erase it and load the snapshot? [y/n]" 97 | answer = STDIN.gets || "" 98 | abort "aborting without restoring the snapshot" unless answer.strip == "y" 99 | break 100 | end 101 | end 102 | 103 | # load the original schema 104 | load("#{from}.schema") 105 | 106 | # load the data 107 | data = YAML.load_file(from) 108 | relative_to = Time.parse(data[:now].to_s) 109 | 110 | now = (ENV['NOW'] && Time.parse(ENV['NOW'])) || Time.now.utc 111 | today = now.to_date 112 | 113 | data[:tables].each do |table| 114 | table_name = table[:table] 115 | 116 | STDERR.puts "- #{table_name} (#{table[:rows].length} rows)..." 117 | 118 | columns = conn.columns(table_name) 119 | fields = columns.map { |c| conn.quote_column_name(c.name) }.join(",") 120 | pfx = "INSERT INTO #{conn.quote_table_name(table_name)} (#{fields}) VALUES (" 121 | sfx = ")" 122 | 123 | table[:rows].each do |row| 124 | values = columns.map do |c| 125 | value = case c.type 126 | when :datetime then 127 | now - (relative_to - Time.parse(row[c.name].to_s)) if row[c.name] 128 | when :date 129 | today - (relative_to.to_date - Date.parse(row[c.name].to_s)) if row[c.name] 130 | else 131 | row[c.name] 132 | end 133 | conn.quote(value, c) 134 | end 135 | 136 | conn.insert_sql(pfx + values.join(",") + sfx) 137 | end 138 | end 139 | 140 | DBSnapshot.restore!(from) 141 | 142 | # make sure we're all up-to-date, schema-wise 143 | migrator = ActiveRecord::Migrator.new(:up, "db/migrate/") 144 | 145 | if migrator.pending_migrations.any? 146 | STDERR.puts "catching up on migrations..." 147 | migrator.migrate 148 | Rake::Task["db:snapshot"].invoke 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /snapshot.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'snapshot' 3 | s.version = '1.1.0' 4 | s.author = 'Jamis Buck' 5 | s.email = 'jamis@37signals.com' 6 | s.summary = 'Rake tasks for manipulating development DB snapshots via ActiveRecord' 7 | s.files = Dir['lib/**/*', 'README.mkd'] 8 | end 9 | --------------------------------------------------------------------------------