ar_database_duplicator 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +11 -0
- data/ar_database_duplicator.gemspec +36 -0
- data/lib/ar_database_duplicator.rb +834 -0
- data/lib/ar_database_duplicator/version.rb +3 -0
- data/test/db/sample_schema.rb +50 -0
- data/test/lib/ar_database_duplicator_test.rb +679 -0
- data/test/test_helper.rb +24 -0
- metadata +214 -0
checksums.yaml
ADDED
@@ -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
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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
|
data/Rakefile
ADDED
@@ -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
|
+
|