git_friendly_dumper 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ Given /I am in an empty app/ do
2
+ steps %q{
3
+ Given a directory named "app"
4
+ Given I cd to "app"
5
+ }
6
+ in_current_dir { `ln -s ../../../lib lib` }
7
+ end
8
+
9
+ Given /a Rakefile exists which has an environment task and loads git_friendly_dumper tasks/ do
10
+ steps %q{
11
+ Given a file named "Rakefile" with:
12
+ """
13
+ $LOAD_PATH.unshift("lib")
14
+ require 'rake'
15
+ require 'active_record'
16
+
17
+ load "lib/tasks/git_friendly_dumper_tasks.rake"
18
+
19
+ task :environment do
20
+ require 'active_record'
21
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => "test.sqlite3")
22
+ end
23
+ """
24
+ }
25
+ end
@@ -0,0 +1,172 @@
1
+ Given /^there is a connection$/ do
2
+ ActiveRecord::Base.connection.should_not be_nil
3
+ end
4
+
5
+ Given /^an empty database$/ do
6
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => "#{current_dir}/test.sqlite3")
7
+ db_table_names.each do |table|
8
+ ActiveRecord::Base.connection.drop_table table
9
+ end
10
+ end
11
+
12
+ When /^I refresh the database tables cache$/ do
13
+ # prevents exception SQLite3::SchemaChangedException: no such table: users: SELECT * FROM "users" (ActiveRecord::StatementInvalid)
14
+ # TODO: is there a public API for this?
15
+ ActiveRecord::Base.connection.tables
16
+ end
17
+
18
+ When(/^I execute the schema "([^"]*)"$/) do |schema_path|
19
+ schema_definition = File.read(File.join(current_dir, schema_path))
20
+ ActiveRecord::Migration.suppress_messages do
21
+ ActiveRecord::Schema.define do
22
+ eval schema_definition
23
+ end
24
+ end
25
+ end
26
+
27
+ Then(/^a "([^"]*)" table should exist with structure:$/) do |table_name, table|
28
+ # table is a Cucumber::Ast::Table
29
+ # TODO: make this db independent, currenlty for sqlite3 only
30
+ table.diff! ActiveRecord::Base.connection.send :table_structure, table_name
31
+ end
32
+
33
+ Then /^list the table names$/ do
34
+ announce db_table_names.to_sentence
35
+ end
36
+
37
+ Then(/^the database should have tables:$/) do |table|
38
+ table.diff! db_table_names.map {|c| [c]}, :surplus_row => true, :surplus_col => true
39
+ end
40
+
41
+ Then(/^the database should not have table "([^"]*)"$/) do |table_name|
42
+ db_table_names.should_not include(table_name)
43
+ end
44
+
45
+ Then /^show me the tables$/ do
46
+ db_table_names.each do |table_name|
47
+ pp table_name
48
+ pp(table_contents(table_name))
49
+ end
50
+ end
51
+
52
+ Then /^show me the "([^"]*)" table$/ do |table_name|
53
+ announce table_contents(table_name).to_yaml
54
+ end
55
+
56
+ Given /^the database has a "([^"]*)" table( \(with timestamps\))?:$/ do |table_name, timestamps, table|
57
+ create_table(table_name, timestamps)
58
+
59
+ columns = []
60
+ table.headers.each do |column_def|
61
+ raise "column_def should look like 'column_name (type)'" unless match = column_def.match(/(\w+) \((\w+)\)/)
62
+ add_column_to_table(table_name, match[1], match[2])
63
+ columns << match[1]
64
+ end
65
+
66
+ table.rows.each do |row|
67
+ attrs = {}
68
+ columns.each_with_index do |column_name, index|
69
+ attrs[column_name] = row[index]
70
+ end
71
+ insert_record_into_table(table_name, attrs)
72
+ end
73
+ end
74
+
75
+ Then /^the "([^"]*)" table should match exactly:$/ do |table_name, table|
76
+ table.diff! table_to_strings(table_contents(table_name)), :surplus_col => true
77
+ end
78
+
79
+ Then /^the "([^"]*)" table should match exactly \(ignoring (ids)?(?: and )?(timestamps)?\):$/ do |table_name, ids, timestamps, table|
80
+ table.diff! table_to_strings(table_contents(table_name, :ids => !ids, :timestamps => !timestamps)), :surplus_col => true
81
+ end
82
+
83
+ When /^I destroy record (\d+) from the "([^"]*)" table$/ do |id, table_name|
84
+ class_for_table(table_name).destroy(id)
85
+ end
86
+
87
+ Then /^the data in the dumped "([^"]*)" yaml files should match the database contents$/ do |table_name|
88
+ records = class_for_table(table_name).all
89
+ fixtures = fixtures_for_table(table_name)
90
+ records.count.should == fixtures.length
91
+ records.each {|record| match_fixture_file_against_record(record)}
92
+ end
93
+
94
+ module FixtureHelpers
95
+ def fixture_path_for(record)
96
+ fixture_id_path = ("%08d" % record.id).scan(/..../).join('/')
97
+ File.join current_dir, "db/dump", record.class.table_name, "#{fixture_id_path}.yml"
98
+ end
99
+
100
+ def replace_record_with_fixture!(record)
101
+ require 'active_record/fixtures'
102
+ fixture_path =fixture_path_for(record)
103
+ fixture = ActiveRecord::Fixture.new(YAML.load(File.read(fixture_path)), record.class)
104
+ record.destroy
105
+ ActiveRecord::Base.connection.insert_fixture fixture, record.class.table_name
106
+ record.class.find(record.id)
107
+ end
108
+
109
+ def match_fixture_file_against_record(record)
110
+ record.attributes.dup.should == replace_record_with_fixture!(record).attributes
111
+ announce "#{table_name.singularize} #{record.id} data matches its fixture data #{fixture_path_for(record)}" if @announce
112
+ end
113
+
114
+ def fixtures_for_table(table_name)
115
+ fixtures = Dir.glob File.join(current_dir, "db/dump", table_name, '**', '*.yml')
116
+ announce "Fixtures for #{table_name}:\n#{fixtures.join("\n")}\n" if @announce
117
+ fixtures
118
+ end
119
+ end
120
+ World(FixtureHelpers)
121
+
122
+ module DatabaseHelpers
123
+ def create_table(name, timestamps = false)
124
+ ActiveRecord::Base.connection.create_table name do |t|
125
+ t.timestamps if timestamps
126
+ end
127
+ end
128
+
129
+ def add_column_to_table(table_name, column_name, type)
130
+ ActiveRecord::Base.connection.change_table table_name do |t|
131
+ t.send type, column_name
132
+ end
133
+ end
134
+
135
+ def class_for_table(table_name)
136
+ @class_for_table ||= {}
137
+ @class_for_table[table_name] ||= begin
138
+ Class.new(ActiveRecord::Base).tap {|klass| klass.table_name = table_name }
139
+ end
140
+ end
141
+
142
+ def insert_record_into_table(table_name, attrs)
143
+ class_for_table(table_name).create! attrs
144
+ end
145
+
146
+ #returns ['table', 'names'] with sqlite adapter
147
+ def db_table_names
148
+ ActiveRecord::Base.connection.tables
149
+ end
150
+
151
+ # table_contents 'users' # gives back everything
152
+ # table_contents 'users', :timestamps => false # without timestamps
153
+ # table_contents 'users', :ids => false # without ids
154
+ def table_contents(table_name, opts={:timestamps => true, :ids => true})
155
+ contents = class_for_table(table_name).all.map(&:attributes)
156
+ contents.tap do |contents|
157
+ contents.map{|c| c.delete('id')} unless opts[:ids]
158
+ contents.map{|c| c.delete('updated_at'); c.delete('created_at')} unless opts[:timestamps]
159
+ end
160
+ end
161
+
162
+ #because cucumber table#diff! expects types to match and step transforms are silly
163
+ def table_to_strings(table)
164
+ table.each do |row|
165
+ row.each_pair do |key, value|
166
+ row[key] = value.is_a?(Time) ? value.utc.to_s(:db) : value.to_s
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ World(DatabaseHelpers)
@@ -0,0 +1,3 @@
1
+ Before('@announce') do
2
+ @announce = true
3
+ end
@@ -0,0 +1,2 @@
1
+ require 'rspec/expectations'
2
+ require 'aruba/cucumber'
@@ -0,0 +1,7 @@
1
+ require 'logger'
2
+ require 'active_record'
3
+
4
+ log_filename = File.join(File.dirname(__FILE__), '..', '..', 'tmp', 'test.log')
5
+ `mkdir -p #{File.dirname(log_filename)}`
6
+ log_file = File.open(log_filename, 'w')
7
+ ActiveRecord::Base.logger = Logger.new(log_file)
@@ -0,0 +1,279 @@
1
+ require 'fileutils'
2
+ require 'active_support/all'
3
+ require 'active_record/fixtures'
4
+
5
+ begin; require 'progressbar'; rescue MissingSourceFile; end
6
+
7
+ # Database independent and git friendly replacement for mysqldump for rails projects
8
+ class GitFriendlyDumper
9
+ include FileUtils
10
+
11
+ attr_accessor :root, :path, :connection, :tables, :force, :include_schema, :show_progress, :clobber_fixtures, :limit, :raise_error, :fixtures
12
+ alias_method :include_schema?, :include_schema
13
+ alias_method :clobber_fixtures?, :clobber_fixtures
14
+ alias_method :show_progress?, :show_progress
15
+ alias_method :force?, :force
16
+ alias_method :raise_error?, :raise_error
17
+
18
+ class << self
19
+ def dump(options = {})
20
+ new(options).dump
21
+ end
22
+
23
+ def load(options = {})
24
+ new(options).load
25
+ end
26
+ end
27
+
28
+ def initialize(options = {})
29
+ options.assert_valid_keys(:root, :path, :connection, :connection_name, :tables, :force, :include_schema, :show_progress, :clobber_fixtures, :limit, :raise_error, :fixtures, :fixtures_file)
30
+
31
+ self.root = options[:root] || (defined?(Rails) && Rails.root) || pwd
32
+
33
+ if options[:fixtures_file]
34
+ raise ArgumentError, "GitFriendlyDumper cannot specify both :fixtures and :fixtures_file" if options[:fixtures].present?
35
+ options[:fixtures] = File.read(options[:fixtures_file]).split("\n").map(&:squish).reject(&:blank?)
36
+ end
37
+
38
+ if options[:fixtures] && (options[:include_schema] || options[:clobber_fixtures])
39
+ raise ArgumentError, "GitFriendlyDumper if :fixtures option given, neither :include_schema nor :clobber_fixtures can be given"
40
+ end
41
+
42
+ if options[:show_progress] && !defined?(ProgressBar)
43
+ raise RuntimeError, "GitFriendlyDumper requires the progressbar gem for progress option.\n sudo gem install progressbar"
44
+ end
45
+
46
+ self.path = File.expand_path(options[:path] || 'db/dump')
47
+ self.tables = options[:tables]
48
+ self.fixtures = options[:fixtures]
49
+ self.limit = options.key?(:limit) ? options[:limit].to_i : 2500
50
+ self.raise_error = options.key?(:raise_error) ? options[:raise_error] : true
51
+ self.force = options.key?(:force) ? options[:force] : false
52
+ self.include_schema = options.key?(:include_schema) ? options[:include_schema] : false
53
+ self.show_progress = options.key?(:show_progress) ? options[:show_progress] : false
54
+ self.clobber_fixtures = options.key?(:clobber_fixtures) ? options[:clobber_fixtures] : (options[:tables].blank? ? true : false)
55
+ self.connection = options[:connection] || begin
56
+ if options[:connection_name]
57
+ ActiveRecord::Base.establish_connection(options[:connection_name])
58
+ end
59
+ ActiveRecord::Base.connection
60
+ end
61
+ end
62
+
63
+ def dump
64
+ if fixtures
65
+ raise ArgumentError, "Cannot dump when :fixtures option is given"
66
+ end
67
+ self.tables ||= db_tables
68
+ tables.delete('schema_migrations') unless include_schema?
69
+ if force? || (tables & fixtures_tables).empty? || confirm?(:dump)
70
+ puts "Dumping data#{' and structure' if include_schema?} from #{current_database_name} to #{path.sub("#{root}/",'')}\n"
71
+ clobber_all_fixtures if clobber_fixtures?
72
+ connection.transaction do
73
+ tables.each {|table| dump_table(table) }
74
+ end
75
+ end
76
+ end
77
+
78
+ def load
79
+ fixtures ? load_fixtures : load_tables
80
+ end
81
+
82
+ private
83
+ def current_database_name
84
+ @current_database_name ||= (connection.respond_to?(:current_database) && connection.current_database) || 'database'
85
+ end
86
+
87
+ def confirm?(type)
88
+ dump_path = path.sub("#{root}/", '')
89
+ if clobber_fixtures? && type == :dump
90
+ puts "\nWARNING: all fixtures in #{dump_path}"
91
+ else
92
+ puts "\nWARNING: the following #{type == :dump ? 'fixtures' : 'tables'} in #{type == :dump ? dump_path : current_database_name}:"
93
+ puts " " + tables.join("\n ")
94
+ end
95
+ if fixtures
96
+ puts "will have records replaced by the specified #{fixtures.length} fixtures (deleting if fixture file is missing)"
97
+ else
98
+ puts "will be replaced with #{type == :dump ? 'records' : 'fixtures'}#{' and table schemas' if include_schema?} from #{type == :dump ? current_database_name : dump_path}."
99
+ end
100
+ puts "Do you wish to proceed? (type 'yes' to proceed)"
101
+ proceed = ($stdin.gets.downcase.strip == 'yes')
102
+ puts "#{type.to_s.capitalize} cancelled at user's request." unless proceed
103
+ proceed
104
+ end
105
+
106
+ def fixtures_tables
107
+ @fixture_tables ||= Dir[File.join(path, '*')].select{|f| File.directory?(f)}.map{|f| File.basename(f)}
108
+ end
109
+
110
+ def db_tables
111
+ @db_tables ||= connection.tables
112
+ end
113
+
114
+ def load_tables
115
+ self.tables ||= fixtures_tables
116
+ tables.delete('schema_migrations') unless include_schema?
117
+ if force? || (tables & db_tables).empty? || confirm?(:load)
118
+ puts "Loading data#{' and structure' if include_schema?} into #{current_database_name} from #{path.sub("#{root}/",'')}\n"
119
+ connection.transaction do
120
+ tables.each {|table| load_table(table) }
121
+ end
122
+ end
123
+ end
124
+
125
+ def load_fixtures
126
+ fixtures_tables = []
127
+ fixtures.map! do |fixture|
128
+ raise ArgumentError, "Fixture filename error: #{fixture} should be a relative filename e.g. users/0000/0001.yml" unless fixture =~ /^\w+\/\d+\/\d+\.yml$/
129
+ table = fixture.split('/').first
130
+ if (!tables || tables.include?(table))
131
+ unless fixtures_tables.include?(table)
132
+ begin
133
+ "::#{table.classify}".constantize
134
+ rescue NameError
135
+ eval "class ::#{table.classify} < ActiveRecord::Base; end"
136
+ end
137
+ fixtures_tables << table
138
+ end
139
+ fixture
140
+ end
141
+ end
142
+ fixtures.compact!
143
+
144
+ self.tables = fixtures_tables
145
+
146
+ if force? || (tables & db_tables).empty? || confirm?(:load)
147
+ puts "Loading fixtures into #{current_database_name} from #{path.sub("#{root}/",'')}\n"
148
+ show_progress? && (progress_bar = ProgressBar.new("fixtures", fixtures.length))
149
+ connection.transaction do
150
+ fixtures.each do |fixture|
151
+ match_data = fixture.match(/(\w+)\/(.+)\.yml/)
152
+ table, id, file = match_data[1], match_data[2].sub('/','').to_i, File.join(path, fixture)
153
+
154
+ raise "Couldn't determine id from #{fixture} (id was #{id})" if id < 1
155
+ connection.delete("DELETE FROM #{table} WHERE id=#{id};")
156
+ load_fixture(table.classify.constantize, table, file) if File.exist?(file)
157
+ show_progress? && progress_bar.inc
158
+ end
159
+ end
160
+ show_progress && progress_bar.finish
161
+ end
162
+ end
163
+
164
+ def dump_table(table)
165
+ clobber_fixtures_for_table(table)
166
+ count = connection.select_value("SELECT COUNT(*) FROM %s" % table).to_i
167
+ show_progress? && (progress_bar = ProgressBar.new(table, count))
168
+
169
+ offset = 0
170
+ while (records = select_records(table, offset)).any?
171
+ dump_records(table, records, show_progress? && progress_bar)
172
+ offset += limit
173
+ end
174
+
175
+ show_progress? && progress_bar.finish
176
+ dump_table_schema(table) if include_schema?
177
+ rescue ActiveRecord::ActiveRecordError => e
178
+ puts "dumping #{table} failed: #{e.message}"
179
+ puts "Partial dump files have been left behind and you should clean up before continuing (e.g. git status, git checkout, git clean)."
180
+ raise e if raise_error?
181
+ end
182
+
183
+ def select_records(table, offset)
184
+ connection.select_all("SELECT * FROM %s LIMIT #{limit} OFFSET #{offset}" % table)
185
+ end
186
+
187
+ def dump_records(table, records, progress_bar)
188
+ records.each_with_index do |record, index|
189
+ id = record['id'] ? record['id'].to_i : index + 1
190
+ fixture_file = File.join(path, table, *id_path(id)) + ".yml"
191
+ `mkdir -p #{File.dirname(fixture_file)}`
192
+ File.open(fixture_file, "w") do |record_file|
193
+ record_file.write record.to_yaml
194
+ end
195
+ show_progress? && progress_bar.inc
196
+ end
197
+ end
198
+
199
+ def load_table(table)
200
+ # create a placeholder AR class for the table without loading anything from the app.
201
+ klass = eval "class #{table.classify} < ActiveRecord::Base; end"
202
+ include_schema? ? load_table_schema(table) : clobber_records(table)
203
+ files = Dir[File.join(path, table, '**', '*.yml')]
204
+ show_progress? && (progress_bar = ProgressBar.new(table, files.length))
205
+ files.each do |file|
206
+ load_fixture(klass, table, file)
207
+ show_progress? && progress_bar.inc
208
+ end
209
+ show_progress? && progress_bar.finish
210
+ rescue ActiveRecord::ActiveRecordError => e
211
+ puts "loading #{table} failed - check log for details"
212
+ raise e if raise_error?
213
+ end
214
+
215
+ def load_fixture(klass, table, file)
216
+ fixture = ActiveRecord::Fixture.new(YAML.load(File.read(file)), klass)
217
+ begin
218
+ connection.insert_fixture fixture, table
219
+ rescue ActiveRecord::ActiveRecordError => e
220
+ puts "loading fixture #{file} failed - check log for details"
221
+ raise e if raise_error?
222
+ end
223
+ end
224
+
225
+ def dump_table_schema(table)
226
+ File.open(File.join(path, table, 'schema.rb'), "w") do |schema_file|
227
+ if table == 'schema_migrations'
228
+ schema_file.write schema_migrations_schema
229
+ else
230
+ schema_dumper.send :table, table, schema_file
231
+ end
232
+ end
233
+ end
234
+
235
+ def schema_migrations_schema
236
+ <<-end_eval
237
+ create_table "schema_migrations", :force => true, :id => false do |t|
238
+ t.string "version", :null => false
239
+ end
240
+ add_index :schema_migrations, :version, :unique => true, :name => 'unique_schema_migrations'
241
+ end_eval
242
+ end
243
+
244
+ def schema_dumper
245
+ @schema_dumper ||= ActiveRecord::SchemaDumper.send :new, @connection
246
+ end
247
+
248
+ def load_table_schema(table)
249
+ schema_definition = File.read(File.join(path, table, 'schema.rb'))
250
+ ActiveRecord::Migration.suppress_messages do
251
+ ActiveRecord::Schema.define do
252
+ eval schema_definition
253
+ end
254
+ end
255
+ end
256
+
257
+ def clobber_fixtures_for_table(table)
258
+ `rm -rf #{File.join(path, table)}`
259
+ `mkdir -p #{File.join(path, table)}`
260
+ end
261
+
262
+ def clobber_all_fixtures
263
+ fixtures_tables.each {|table| clobber_fixtures_for_table(table)}
264
+ end
265
+
266
+ def clobber_records(table)
267
+ connection.delete "DELETE FROM #{table}"
268
+ end
269
+
270
+ # Partitions the given id into an array of path components.
271
+ #
272
+ # For example, given an id of 1
273
+ # <tt>["0000", "0001"]</tt>
274
+ #
275
+ # Currently only integer ids are supported
276
+ def id_path(id)
277
+ ("%08d" % id).scan(/..../)
278
+ end
279
+ end