git_friendly_dumper 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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