ar_database_duplicator 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9317ed67cc635818a1fb85eeca0f76c790f676d7
4
+ data.tar.gz: 563156d176d3d766be7737a7ffc1f9b52f7f909b
5
+ SHA512:
6
+ metadata.gz: 731cc7638ffcfa33b82c7f9419d1c14ee1cdbab8d7b8c6e67112ad614eb22820371438076674de15516f95377469b194b8e674505b2da6703343dd0ca8b80e48
7
+ data.tar.gz: 7867c7170a79e8276c8ac4a951bf757586f41ace23e5e2d81973e5a384faf8322030004f1fbaf094d7f04312ab1256e13f2654850ea69ca8b6a69399d43afd48
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in ar_database_duplicator.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Frank Hall
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,29 @@
1
+ # ArDatabaseDuplicator
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'ar_database_duplicator'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install ar_database_duplicator
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new do |t|
6
+ t.libs << 'test/lib'
7
+ t.test_files = FileList['test/lib/*test.rb']
8
+ end
9
+
10
+ desc "Run tests"
11
+ task :default => :test
@@ -0,0 +1,36 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'ar_database_duplicator/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ar_database_duplicator"
8
+ spec.version = ArDatabaseDuplicator::VERSION
9
+ spec.authors = ["Frank Hall"]
10
+ spec.email = ["ChapterHouse.Dune@gmail.com"]
11
+ spec.description = %q{Duplicate a complete or partial database with ActiveRecord while controlling sensitive values.}
12
+ spec.summary = %q{Duplicate a complete or partial database with ActiveRecord while controlling sensitive values.}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+ spec.add_development_dependency 'mocha'
24
+ spec.add_development_dependency "shoulda"
25
+ spec.add_development_dependency 'minitest'
26
+ spec.add_development_dependency 'minitest-reporters'
27
+
28
+
29
+ spec.add_runtime_dependency "pseudo_entity", ">= 0.0.5"
30
+ spec.add_runtime_dependency "ruby-progressbar", "~> 1.0"
31
+ spec.add_runtime_dependency "activerecord", ">= 2.3"
32
+ spec.add_runtime_dependency "sqlite3"
33
+ spec.add_runtime_dependency "encryptor2", "~> 1.0"
34
+
35
+ end
36
+
@@ -0,0 +1,834 @@
1
+ require "active_record"
2
+ require 'pseudo_entity'
3
+ require 'ruby-progressbar'
4
+ require 'forwardable'
5
+ require 'encryptor'
6
+
7
+ module ActiveRecord
8
+
9
+ module VettedRecord
10
+
11
+ class UnvettedAttribute < Exception
12
+ end
13
+
14
+ def self.included(base)
15
+ class << base
16
+ attr_accessor :field_vetting
17
+
18
+ def field_vetting
19
+ @field_vetting.nil? ? @field_vetting = true : @field_vetting
20
+ end
21
+
22
+ def mark_attribute_safe(name)
23
+ safe_attributes << name.to_s
24
+ safe_attributes.uniq!
25
+ end
26
+
27
+ def mark_attribute_temporarily_safe(name)
28
+ temporary_safe_attributes << name.to_s
29
+ temporary_safe_attributes.uniq!
30
+ end
31
+
32
+ def safe_attributes
33
+ @safe_attributes ||= []
34
+ end
35
+
36
+ # These are attributes that are to be considered safe at the class level but only for a specific period of time.
37
+ def temporary_safe_attributes
38
+ @temporary_safe_attributes ||= []
39
+ end
40
+
41
+ def clear_temporary_safe_attributes
42
+ @temporary_safe_attributes = nil
43
+ end
44
+
45
+ # An array of attributes not already vetted at the class level
46
+ def unvetted_attributes
47
+ column_names - vetted_attributes
48
+ end
49
+
50
+ # An array of attributes already vetted at the class level
51
+ def vetted_attributes
52
+ field_vetting ? (safe_attributes + temporary_safe_attributes) : column_names
53
+ end
54
+
55
+ def with_field_vetting(&block)
56
+ old_state = field_vetting
57
+ begin
58
+ self.field_vetting = true
59
+ yield
60
+ ensure
61
+ self.field_vetting = old_state
62
+ end
63
+ end
64
+
65
+ def without_field_vetting(&block)
66
+ old_state = field_vetting
67
+ begin
68
+ self.field_vetting = false
69
+ yield
70
+ ensure
71
+ self.field_vetting = old_state
72
+ end
73
+ end
74
+
75
+ end
76
+ end
77
+
78
+ def unvetted_attributes
79
+ # Start with all attributes not vetted at the class level.
80
+ # Remove any attributes that were unchanged but marked as safe
81
+ # Remove any attributes that were changed
82
+ # And what you have left is unvetted attributes. Most likely a new field was added or a value was not given for an existing one.
83
+ self.class.unvetted_attributes - vetted_attributes - changed_attributes.keys
84
+ end
85
+
86
+ def vetted?
87
+ unvetted_attributes.empty?
88
+ end
89
+
90
+ # If an attribute for this instance is to be considered safe without being overwritten, mark it as vetted.
91
+ def vet_attribute(name)
92
+ vetted_attributes << name.to_s
93
+ vetted_attributes.uniq!
94
+ end
95
+
96
+ def vetted_attributes
97
+ @vetted_attributes ||= []
98
+ end
99
+
100
+ # This will only save if there are no unvetted attributes.
101
+ def vetted_save
102
+ raise UnvettedAttribute, "The following field(s) were not checked: #{unvetted_attributes.join(', ')}" unless vetted?
103
+ save_without_validation
104
+ end
105
+
106
+ end
107
+
108
+ class Base
109
+ include VettedRecord
110
+ end
111
+
112
+ end
113
+
114
+
115
+ class ARDatabaseDuplicator
116
+
117
+ # Allow this class to be used as a singleton without absolutely enforcing it.
118
+ extend SingleForwardable
119
+ def_delegators :instance, :source, :source=, :destination, :destination=, :schema_file, :schema_file=, :force, :force=, :silent, :silent=, :test, :test=, :split_data, :split_data=,
120
+ :use_source, :use_destination, :load_schema, :duplicate, :while_silent, :while_not_silent, :define_class
121
+
122
+
123
+ attr_accessor :source, :destination, :schema_file, :force, :silent, :test, :split_data
124
+
125
+ def initialize(options={})
126
+ @source = options[:source] || 'development'
127
+ @destination = options[:destination] || 'dev_data'
128
+ @schema_file = options[:schema_file] || 'db/schema.rb'
129
+ @force = options.fetch(:force) { false }
130
+ @test = options.fetch(:test) { false }
131
+ @split_data = options.fetch(:split_data) { true }
132
+ end
133
+
134
+ def use_source(subname=nil)
135
+ use_connection source, subname
136
+ end
137
+
138
+ def use_destination(subname=nil)
139
+ use_connection destination, subname
140
+ end
141
+
142
+ def destination=(new_value)
143
+ raise ArgumentError, "Production is not an allowed duplication destination." if new_value.downcase == "production"
144
+ @destination_directory_exists = false
145
+ @destination = new_value
146
+ end
147
+
148
+ def split_data=(new_value)
149
+ @destination_directory_exists = false
150
+ @split_data = new_value
151
+ end
152
+
153
+ def load_duplication(klass)
154
+ raise ArgumentError, "Production must be duplicated, not loaded from." if source.downcase == "production"
155
+ klass = define_class(klass) unless klass.is_a?(Class)
156
+ records = with_source(klass) { klass.all }
157
+ puts "#{records.size} #{plural(klass)} read."
158
+ klass.without_field_vetting { transfer(klass, records) }
159
+ end
160
+
161
+ def load_schema
162
+ # Adding this class just so we can check if a schema has already been loaded
163
+ Object.const_set(:SchemaMigration, Class.new(ActiveRecord::Base)) unless Object.const_defined?(:SchemaMigration)
164
+ split_data ? load_schema_split : load_schema_combined
165
+ end
166
+
167
+ def define_class(name)
168
+ name = name.camelize.to_sym
169
+ Object.const_set(name, Class.new(ActiveRecord::Base)) unless Object.const_defined?(name)
170
+ Object.const_get(name)
171
+ end
172
+
173
+ # Duplicate each record, via ActiveRecord, from the source to the destination database.
174
+ # Field replacements can be given via a hash in the form of :original_field => :pseudo_person_field
175
+ # If a block is passed, the record will be passed for inspection/alteration before
176
+ # it is saved into the destination database.
177
+ def duplicate(klass, replacements={}, *additional_replacements, &block)
178
+ klass = define_class(klass) unless klass.is_a?(Class)
179
+
180
+ plural = plural(klass)
181
+
182
+ automatic_replacements = [replacements] + additional_replacements
183
+ raise(ArgumentError, "Each group of replacements must be given as a Hash") unless automatic_replacements.all? { |x| x.is_a?(Hash) }
184
+
185
+ sti_klasses = []
186
+ set_temporary_vetted_attributes(klass, automatic_replacements)
187
+
188
+ # If we aren't guaranteed to fail on vetting
189
+ if block_given? || !block_required?(klass)
190
+ # If we have potential duplication to do
191
+ if force || !already_duplicated?(klass)
192
+ # Connect to the source database
193
+ with_source do
194
+ # Grab a quick count to see if there is anything we need to do.
195
+ estimated_total = klass.count
196
+ if estimated_total > 0
197
+ inform(test ? "Extracting first 1,000 #{plural} for testing" : "Extracting all #{plural}")
198
+ # Pull in all records. Perhaps later we can enhance this to do it in batches.
199
+ unless singleton?(klass)
200
+ records = test ? klass.find(:all, :limit => 1000) : klass.find(:all)
201
+ else
202
+ records = [klass.instance]
203
+ end
204
+
205
+ # Handle any single table inheritance that may have shown up
206
+ records.map(&:class).uniq.each { |k| sti_klasses << k if k != klass }
207
+ sti_klasses.each { |k| set_temporary_vetted_attributes(k, automatic_replacements) }
208
+
209
+ # Record the size so we can give some progress indication.
210
+ inform "#{records.size} #{plural} read"
211
+
212
+ transfer(klass, records, automatic_replacements, &block)
213
+ else
214
+ inform "Skipping #{plural}. No records exist."
215
+ end
216
+ end
217
+ else
218
+ inform "Skipping #{plural}. Records already exist."
219
+ end
220
+ else
221
+ inform "Skipping #{plural}. The following field(s) were not checked: #{klass.unvetted_attributes.join(', ')}"
222
+ end
223
+
224
+ # Clean things up for the next bit of code that might use this class.
225
+ klass.clear_temporary_safe_attributes
226
+ sti_klasses.each { |k| k.clear_temporary_safe_attributes }
227
+ end
228
+
229
+ def while_silent(&block)
230
+ with_silence_at(true, &block)
231
+ end
232
+
233
+ def while_not_silent(&block)
234
+ with_silence_at(false, &block)
235
+ end
236
+
237
+ def with_source(subname=nil, silent_change=false, &block)
238
+ with_connection(source, subname, silent_change, &block)
239
+ end
240
+
241
+ def with_destination(subname=nil, silent_change=false, &block)
242
+ with_connection(destination, subname, silent_change, &block)
243
+ end
244
+
245
+ # With a specified connection, connect, execute a block, then restore the connection to it's previous state (if any).
246
+ def with_connection(name, subname=nil, silent_change=false, &block)
247
+ old_connection = connection
248
+ begin
249
+ use_connection(name, subname, silent_change)
250
+ result = yield
251
+ ensure
252
+ use_spec(old_connection)
253
+ end
254
+ result
255
+ end
256
+
257
+ def self.instance(options={})
258
+ options[:source] ||= 'development'
259
+ options[:destination] ||= 'dev_data'
260
+ options[:schema] ||= 'db/schema.rb'
261
+ options[:force] = false unless options.has_key?(:force)
262
+ options[:test] = true unless options.has_key?(:test)
263
+ options[:split_data] = true unless options.has_key?(:split_data)
264
+ @duplicator ||= new(options)
265
+ end
266
+
267
+ def self.reset!
268
+ @duplicator = nil
269
+ end
270
+
271
+ private
272
+
273
+ def base_path
274
+ @base_path ||= Rails.root + "db" + "duplication"
275
+ end
276
+
277
+ def destination_directory_exists?
278
+ @destination_directory_exists
279
+ end
280
+
281
+ def destination_directory
282
+ split_data ? base_path + destination : base_path
283
+ end
284
+
285
+ def connection
286
+ @connection
287
+ end
288
+
289
+ def connection=(new_name)
290
+ @connection = new_name
291
+ end
292
+
293
+ def connected_to?(name)
294
+ connection == name
295
+ end
296
+
297
+ def create_destination_directory
298
+ destination_directory.mkpath unless destination_directory.exist?
299
+ @destination_directory_exists = true
300
+ end
301
+
302
+
303
+ def entity
304
+ @entity ||= PseudoEntity.new
305
+ end
306
+
307
+ def inform(message)
308
+ puts message unless silent
309
+ end
310
+
311
+ # Load the schema into the destination database
312
+ def load_schema_combined
313
+ with_destination do
314
+ # If there is no schema or we are forcing things
315
+ if !schema_loaded?
316
+ captured_schema = CapturedSchema.new(self, schema_file)
317
+
318
+ # sqlite3 handles index names at the database level and not at the table level.
319
+ # This can cause issues with adding indexes. Since we wont be depending on them anyway
320
+ # we will just stub this out so we can load the schema without issues.
321
+ #schema_klass = ActiveRecord::Schema
322
+ #
323
+ #def schema_klass.add_index(*args)
324
+ # say_with_time "add_index(#{args.map(&:inspect).join(', ')})" do
325
+ # say "skipped", :subitem
326
+ # end
327
+ #end
328
+ load schema_file
329
+
330
+ ActiveRecord::Schema.define(:version => captured_schema.recorded_assume_migrated[1]) do
331
+ create_table "table_schemas", :force => true do |t|
332
+ t.string "table_name"
333
+ t.text "schema"
334
+ end
335
+ end
336
+ captured_schema.table_names.each do |table_name|
337
+ TableSchema.create(:table_name => table_name, :schema => captured_schema.schema_for(table_name))
338
+ end
339
+ else
340
+ inform 'Skipping schema load. Schema already loaded.'
341
+ end
342
+ end
343
+ end
344
+
345
+ # Load the schema into the separate destination databases. Each db corresponds to one table.
346
+ def load_schema_split
347
+ captured_schema = CapturedSchema.new(self, schema_file)
348
+ no_schema_loaded = true
349
+
350
+ # Now that we know all of the tables, indexes, etc we are ready to split things up into multiple databases for easy transport.
351
+ captured_schema.table_names.sort.each do |table_name|
352
+ if !schema_loaded?(table_name)
353
+ no_schema_loaded = false
354
+ with_destination(table_name) do
355
+ commands = captured_schema.table_commands_for(table_name)
356
+
357
+ ActiveRecord::Schema.define(:version => captured_schema.recorded_assume_migrated[1]) do
358
+ commands.each do |command|
359
+ command = command.dup
360
+ block = command.pop
361
+ self.send(*command, &block)
362
+ end
363
+ create_table "table_schemas", :force => true do |t|
364
+ t.string "table_name"
365
+ t.text "schema"
366
+ end
367
+
368
+ command = captured_schema.recorded_initialize_schema.dup
369
+ block = command.pop
370
+ self.send(*command, &block) unless command.empty?
371
+
372
+ command = captured_schema.recorded_assume_migrated.dup
373
+ block = command.pop
374
+ self.send(*command, &block) unless command.empty?
375
+ end
376
+ TableSchema.create(:table_name => table_name, :schema => captured_schema.schema_for(table_name))
377
+ end
378
+ end
379
+ end
380
+
381
+ inform 'Skipping schema load. Schema already loaded.' if no_schema_loaded
382
+
383
+ end
384
+
385
+
386
+
387
+ def replace_attributes(record, automatic_replacements, &block)
388
+
389
+ # Do any automatic field replacements
390
+ automatic_replacements.each do |replacement_hash|
391
+ # For each hash, reset the pseudo entity and the use it to do replacements.
392
+ entity.reset!
393
+ replace(record, replacement_hash) unless replacement_hash.empty?
394
+ end
395
+
396
+ # Before we save it, pass the newly cloned record to a block for inspection/alteration
397
+ if block_given?
398
+ block_replacements =
399
+ # If the block only wants the record send it in.
400
+ if block.arity == 1
401
+ yield(entity.reset!)
402
+ else
403
+ # Otherwise send in a PseudoEntity with the made up data to be used for field replacement.
404
+ yield(entity.reset!, record)
405
+ end
406
+ replace(record, block_replacements) unless !block_replacements.is_a?(Hash) || block_replacements.empty?
407
+ end
408
+
409
+ end
410
+
411
+ # Replace each value in the target if it is already populated.
412
+ def replace(target, hash)
413
+ hash.each do |key, value_key|
414
+ # We either have a symbol representing a method to call on PseudoEntity or a straight value.
415
+ value = value_key
416
+ # In general we aren't dealing with encrypted data.
417
+ encrypted = false
418
+ # If this is a command we are call to get the value
419
+ if value_key.is_a?(Symbol)
420
+ # If we are replacing an encrypted field
421
+ if value_key.to_s.start_with?('encrypted_')
422
+ encrypted = true
423
+ # Change the command to be the non encrypted version so we can get the actual value.
424
+ value_key = value_key.to_s[10..-1].to_sym
425
+ end
426
+ # Throw an error if we do not recognize the PseudoEntity method
427
+ raise "No replacement defined for #{value_key.inspect}" unless entity.respond_to?(value_key)
428
+ # Grab the actual value we will use for replacement
429
+ value = entity.send(value_key)
430
+ end
431
+
432
+ # If the value is to be encrypted
433
+ if encrypted
434
+ salt_method = "#{key}_salt".to_sym
435
+ iv_method = "#{key}_iv".to_sym
436
+ # If the record has an existing salt then replace it
437
+ if target.respond_to?(salt_method) && !target.send(salt_method).blank?
438
+ salt = entity.reset('salt')
439
+ replace_with(target, salt_method, salt)
440
+ else
441
+ salt = nil
442
+ end
443
+
444
+ # If the record has an existing iv then replace it
445
+ if target.respond_to?(iv_method) && !target.send(iv_method).blank?
446
+ iv = entity.reset('iv')
447
+ replace_with(target, iv_method, iv)
448
+ else
449
+ iv = nil
450
+ end
451
+
452
+ # Use the same combination as I use on my luggage. No one will ever guess that.
453
+ value = value.encrypt(:key => "1234", :salt => salt, :iv => iv)
454
+ end
455
+ replace_with target, key, value
456
+ end
457
+ end
458
+
459
+ # Replace a value in the target if it is already populated.
460
+ def replace_with(target, key, value)
461
+ if value.is_a?(Proc)
462
+ value =
463
+ case value.arity
464
+ when 0
465
+ value.call
466
+ when 1
467
+ value.call(entity)
468
+ when 2
469
+ value.call(entity, target)
470
+ else
471
+ value.call(entity, target, key)
472
+ end
473
+ end
474
+ target.send("#{key}=", value) unless target.send(key).blank?
475
+ target.vet_attribute(key) if target.respond_to?(:vet_attribute)
476
+ end
477
+
478
+ def salt
479
+ entity.class.new.salt
480
+ end
481
+
482
+
483
+ def set_temporary_vetted_attributes(klass, automatic_replacements)
484
+
485
+ # Reset the class to its normal safe attributes. We will not trust that this has been done for us before. Even if we were the last ones to touch this class.
486
+ klass.clear_temporary_safe_attributes
487
+ # Duplication considers the following fields always safe and won't be modifying them.
488
+ klass.mark_attribute_temporarily_safe(:id)
489
+ klass.mark_attribute_temporarily_safe(:created_at)
490
+ klass.mark_attribute_temporarily_safe(:updated_at)
491
+ klass.mark_attribute_temporarily_safe(:deleted_at)
492
+ klass.mark_attribute_temporarily_safe(:lock_version)
493
+ # Take each attributes that we will attempt to automatically replace
494
+ automatic_replacements.each do |replacement_set|
495
+ replacement_set.each do |attr, value|
496
+ # Mark it temporarily safe at the class level.
497
+ # This allows an attribute to be considered vetted if any instance has a nil value and no substitution is performed.
498
+ klass.mark_attribute_temporarily_safe(attr)
499
+ # If PseudoEntity will be using an encrypted version of its attribute
500
+ if value.is_a?(Symbol) && value.to_s.starts_with?("encrypted_")
501
+ # Then it will automatically attempt to populate the salt and iv fields as well. So we can clear those.
502
+ klass.mark_attribute_temporarily_safe "#{attr}_salt"
503
+ klass.mark_attribute_temporarily_safe "#{attr}_iv"
504
+ end
505
+ end
506
+ end
507
+
508
+ end
509
+
510
+ def transfer(klass, records, automatic_replacements={}, &block)
511
+ plural = plural(klass)
512
+ inform "Transferring #{plural}"
513
+
514
+ # Switch to the destination database
515
+ with_destination(klass) do
516
+ problematic_records = []
517
+ # Blow away all callbacks. We are looking at a pure data transfer here.
518
+ clear_callbacks(klass)
519
+
520
+ progress_bar = ProgressBar.create(:title => title_plural(klass), :total => records.size, :format => '%t %p%% [%b>>%i] %c/%C %E ', :smoothing => 0.9)
521
+ # Take each record, replace any data required, and save
522
+ records.each do |record|
523
+ replace_attributes(record, automatic_replacements, &block)
524
+
525
+ # Trick active record into saving this record all over again in its entirety
526
+ record.instance_variable_set(:@new_record, true)
527
+
528
+ # Save without validation as there is no guaranteed order of how the classes will be duplicated. We don't want to trigger any callbacks referencing other tables.
529
+ # Besides, they should have already been validated when they were saved in production.
530
+ begin
531
+ record.vetted_save
532
+ rescue ActiveRecord::StatementInvalid => e
533
+ inform "Problems saving record #{record.id}."
534
+ inform e.message
535
+ inform "Adding record to emergency yaml dump"
536
+ problematic_records << record
537
+ rescue ActiveRecord::VettedRecord::UnvettedAttribute => e
538
+ inform "#{record.class.name}##{record.id} not duplicated for security reasons"
539
+ inform e.message
540
+ rescue => e
541
+ puts "Not good! I just got an #{e.inspect}"
542
+ # Quick cleanup
543
+ klass.clear_temporary_safe_attributes
544
+ sti_klasses.each { |k| k.clear_temporary_safe_attributes }
545
+ raise e
546
+ end
547
+ # Give an update of the percentage transferred
548
+ progress_bar.increment
549
+ end
550
+
551
+ unless problematic_records.blank?
552
+ file_name = "#{destination}.#{klass.name}.yaml"
553
+ inform "Saving #{problematic_records.size} #{plural} to #{file_name}"
554
+ # TODO: Change to deal with split data
555
+ File.open( file_name, 'w' ) { |out| YAML.dump(problematic_records, out) }
556
+ end
557
+
558
+ end
559
+
560
+ inform "All #{plural} transferred"
561
+
562
+ end
563
+
564
+ def title_plural(klass)
565
+ klass.name.titleize.pluralize
566
+ end
567
+
568
+ def plural(klass)
569
+ title_plural(klass).downcase
570
+ end
571
+
572
+ def with_silence_at(value)
573
+ saved_setting = silent
574
+ self.silent = value
575
+ begin
576
+ yield
577
+ ensure
578
+ @silent = saved_setting
579
+ end
580
+ end
581
+
582
+ def already_duplicated?(klass)
583
+ with_destination(klass, true) do
584
+ singleton?(klass) ? klass.count > 0 : !klass.first.nil?
585
+ end
586
+ end
587
+
588
+ def schema_loaded?(subname=nil)
589
+ if force
590
+ false
591
+ else
592
+ define_class('SchemaMigration')
593
+ with_destination(subname, true) { SchemaMigration.table_exists? && SchemaMigration.count > 0 }
594
+ end
595
+ end
596
+
597
+ def singleton?(klass)
598
+ klass.included_modules.map(&:to_s).include?('ActiveRecord::Singleton')
599
+ end
600
+
601
+ # Hopefully this will be rails version agnostic. But knowing my luck... Oh well.
602
+ def clear_callbacks(klass)
603
+ callbacks = [:after_initialize, :after_find, :after_touch, :before_validation, :after_validation, :before_save, :around_save, :after_save,
604
+ :before_create, :around_create, :after_create, :before_update, :around_update, :after_update, :before_destroy, :around_destroy,
605
+ :after_destroy, :after_commit, :after_rollback
606
+ ]
607
+
608
+ callbacks.each do |callback|
609
+ begin
610
+ klass.send(callback).clear
611
+ rescue NoMethodError
612
+ end
613
+ end
614
+
615
+
616
+ end
617
+
618
+ # Returns true if we absolutely know that a block will be required for vetting to pass
619
+ def block_required?(klass)
620
+ with_source(nil, true) { !klass.unvetted_attributes.empty? }
621
+ end
622
+
623
+ def use_connection(name, subname=nil, silent_change=false)
624
+ # If this is a connection defined in the database.yml
625
+ if ActiveRecord::Base.configurations.keys.include?(name)
626
+ # The database spec is the same as the name
627
+ spec = name
628
+ else # Otherwise we are going to use a sqlite3 database specified at runtime
629
+ # Convert from a class to the table name if needed.
630
+ subname = subname.table_name if subname.is_a?(Class) && subname < ActiveRecord::Base
631
+ if name == destination
632
+ # Start with the location the sqlite data will be
633
+ database = destination_directory
634
+ # If we are splitting the data into individual tables
635
+ if split_data
636
+ # Add the subname to the path if one is given
637
+ unless subname.blank?
638
+ database += subname
639
+ else
640
+ # Move up one directory level and add a sqlite3 extension to avoid name collision.
641
+ database = database.parent + "#{destination}.sqlite3"
642
+ end
643
+ else
644
+ # Add a sqlite3 extension to avoid name collisions.
645
+ database += "#{destination}.sqlite3"
646
+ end
647
+ else
648
+ database = Pathname(name.to_s)
649
+ end
650
+ # Create the database spec
651
+ spec = {:adapter => 'sqlite3',:database => database.to_s, :host => 'localhost', :username => 'root'}
652
+ # Set the name to something nice for display
653
+ name = database.basename(database.extname)
654
+ end
655
+
656
+ use_spec(spec, silent_change ? nil : name)
657
+
658
+ end
659
+
660
+ def use_spec(spec, name=nil)
661
+ # If we aren't already connected to the database
662
+ unless connected_to?(spec)
663
+ # Create the directory structure if needed
664
+ create_destination_directory if spec.is_a?(Hash) && (spec[:adapter] == 'sqlite3') && !destination_directory_exists?
665
+ # Give a heads up on the switch
666
+ inform "Switching to #{name}" if name
667
+ # Disconnect any existing connections
668
+ ActiveRecord::Base.clear_active_connections! if connection
669
+ # Make the connection if we were given a new one
670
+ ActiveRecord::Base.establish_connection(spec) if spec
671
+ # Remember where we are connected to so we don't do it again if it isn't necessary
672
+ self.connection = spec
673
+ end
674
+ end
675
+
676
+ end
677
+
678
+ class ARDatabaseDuplicator::CapturedSchema
679
+
680
+ attr_reader :schema, :db, :schema_file_name
681
+
682
+ def initialize(ardb, schema_file_name)
683
+ @db = ardb
684
+ self.schema_file_name = schema_file_name
685
+ parse_schema
686
+ end
687
+
688
+ def table_commands_for(table_name)
689
+ recorded_table_commands[table_name]
690
+ end
691
+
692
+ def schema_for(table_name)
693
+ [create_table_command(table_name), index_commands(table_name)].join("\n")
694
+ end
695
+
696
+ def table_names
697
+ recorded_table_commands.keys
698
+ end
699
+
700
+ def recorded_assume_migrated
701
+ @recorded_assume_migrated ||= []
702
+ end
703
+
704
+ def recorded_initialize_schema
705
+ @recorded_initialize_schema ||= []
706
+ end
707
+
708
+ private
709
+
710
+ def schema=(x)
711
+ @schema = x
712
+ end
713
+
714
+ def schema_file_name=(x)
715
+ @schema_file_name = x
716
+ end
717
+
718
+ def create_table_command(table_name)
719
+ create_command = recorded_table_commands[table_name].find { |x| x.first == :create_table }
720
+ if create_command
721
+ (["create_table #{create_command[0..-2].map(&:inspect).join(', ')} do |t|"] + recorded_table_columns[table_name] + ['end']).join("\n")
722
+ else
723
+ ''
724
+ end
725
+ end
726
+
727
+ def index_commands(table_name)
728
+ recorded_table_commands[table_name].find_all { |x| x.first == :add_index }.inject([]) do |commands, command|
729
+ commands << "add_index " + command[1..-2].map(&:inspect).join(', ')
730
+ end.join("\n")
731
+ end
732
+
733
+ def recorded_table_commands
734
+ @recorded_table_commands ||= Hash.new { |hash, key| hash[key] = [] }
735
+ end
736
+
737
+ def recorded_table_columns
738
+ @recorded_table_columns ||= Hash.new
739
+ end
740
+
741
+
742
+
743
+ def parse_schema
744
+ self.schema = File.read(schema_file_name)
745
+
746
+ # Create the two interceptors.
747
+ # The first for the create table blocks to get the columns, the second for the create_table and add_index commands.
748
+
749
+ # This is the column interceptor.
750
+ table_definition = recording_table_definition
751
+ table_commands = recorded_table_commands
752
+ table_columns = recorded_table_columns
753
+ assume_migrated = recorded_assume_migrated
754
+ initialize_schema = recorded_initialize_schema
755
+ # This interceptor helps us learn what tables we will be defining by intercepting the important schema commands.
756
+ # Additionally determine the final assume_migrated_upto_version arguments.
757
+ # These will be used for each sub database created.
758
+ # The 1.8.x style of define_singleton_method
759
+ schema_klass_singleton = class << ActiveRecord::Schema; self; end
760
+ schema_klass_singleton.send(:define_method, :method_missing) do |name, *arguments, &block|
761
+ if name.to_sym == :create_table
762
+ # Pull out the table name
763
+ table_name = arguments.first
764
+ # Record the creation command
765
+ table_commands[table_name] << ([name] + arguments + [block] )
766
+
767
+ # Now lets get what is inside that block so we know what columns there are.
768
+ # Start with no columns
769
+ table_definition.column_commands = []
770
+ # Call the block with our recorder (instead of a normal table definition instance)
771
+ block.call(table_definition)
772
+ # Save off all of the column commands
773
+ table_columns[table_name] = table_definition.column_commands
774
+ elsif name.to_sym == :add_index
775
+ table_commands[arguments.first] << ([name] + arguments + [block] )
776
+ elsif name.to_sym == :assume_migrated_upto_version
777
+ assume_migrated.replace ([name] + arguments + [block] )
778
+ elsif name.to_sym == :initialize_schema_migrations_table
779
+ initialize_schema.replace ([name] + arguments + [block] )
780
+ end
781
+ end
782
+
783
+
784
+ # Now with the above interceptors/recorders in place, eval the schema capture all of the data.
785
+ # This is a safety thing. Just in case examining the schema causes a change
786
+ # (which it never should) we don't want to touch our source.
787
+ db.with_connection("schema_eval", nil, true) do
788
+ eval(schema)
789
+ end
790
+
791
+ # Now to remove the interceptor/recorders defined above
792
+ schema_klass_singleton.send(:remove_method, :method_missing)
793
+
794
+ end
795
+
796
+ def recording_table_definition
797
+ unless @recording_table_definition
798
+ @recording_table_definition = ActiveRecord::ConnectionAdapters::TableDefinition.new(nil)
799
+ @recording_table_definition.instance_eval <<-EOV, __FILE__, __LINE__ + 1
800
+ def column_commands
801
+ @column_commands
802
+ end
803
+
804
+ def column_commands=(x)
805
+ @column_commands = x
806
+ end
807
+ EOV
808
+
809
+ %w( string text integer float decimal datetime timestamp time date binary boolean ).each do |column_type|
810
+ @recording_table_definition.instance_eval <<-EOV, __FILE__, __LINE__ + 1
811
+ def #{column_type}(*args)
812
+ column_options = args.extract_options!
813
+ column_names = args
814
+ command_args = column_options.map { |x| x.map(&:inspect).join(' => ') }
815
+ column_names.each do |name|
816
+ column_commands << " t.#{column_type} " + command_args.unshift(name.inspect).join(', ')
817
+ end
818
+ end
819
+ EOV
820
+ end
821
+ end
822
+ @recording_table_definition
823
+ end
824
+
825
+
826
+ end
827
+
828
+
829
+ class ARDatabaseDuplicator::TableSchema < ActiveRecord::Base
830
+ #self.table_name = "table_schema"
831
+ end
832
+
833
+
834
+