replicate 1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,54 @@
1
+ module Replicate
2
+ # Base class for Dumper / Loader classes. Manages a list of callback listeners
3
+ # and dispatches to each when #emit is called.
4
+ class Emitter
5
+ # Yields self to the block and calls #complete when block is finished.
6
+ def initialize
7
+ @listeners = []
8
+ if block_given?
9
+ yield self
10
+ complete
11
+ end
12
+ end
13
+
14
+ # Register a listener to be called for each loaded object with the
15
+ # type, id, attributes, object structure. Listeners are executed in the
16
+ # reverse order of which they were registered. Listeners registered later
17
+ # modify the view of listeners registered earlier.
18
+ #
19
+ # p - An optional Proc object. Must respond to call.
20
+ # block - An optional block.
21
+ #
22
+ # Returns nothing.
23
+ def listen(p=nil, &block)
24
+ @listeners.unshift p if p
25
+ @listeners.unshift block if block
26
+ end
27
+
28
+ # Sugar for creating a listener with an object instance. Instances of the
29
+ # class must respond to call(type, id, attrs, object).
30
+ #
31
+ # klass - The class to create. Must respond to new.
32
+ # args - Arguments to pass to new in addition to self.
33
+ #
34
+ # Returns the object created.
35
+ def use(klass, *args, &block)
36
+ instance = klass.new(self, *args, &block)
37
+ listen instance
38
+ instance
39
+ end
40
+
41
+ # Emit an object event to each listener.
42
+ #
43
+ # Returns the object.
44
+ def emit(type, id, attributes, object)
45
+ @listeners.each { |p| p.call(type, id, attributes, object) }
46
+ object
47
+ end
48
+
49
+ # Notify all listeners that processing is complete.
50
+ def complete
51
+ @listeners.each { |p| p.complete if p.respond_to?(:complete) }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,133 @@
1
+ module Replicate
2
+ # Load replicants in a streaming fashion.
3
+ #
4
+ # The Loader reads [type, id, attributes] replicant tuples and creates
5
+ # objects in the current environment.
6
+ #
7
+ # Objects are expected to arrive in order such that a record referenced via
8
+ # foreign key always precedes the referencing record. The Loader maintains a
9
+ # mapping of primary keys from the dump system to the current environment.
10
+ # This mapping is used to properly establish new foreign key values on all
11
+ # records inserted.
12
+ class Loader < Emitter
13
+
14
+ # Stats hash.
15
+ attr_reader :stats
16
+
17
+ def initialize
18
+ @keymap = Hash.new { |hash,k| hash[k] = {} }
19
+ @stats = Hash.new { |hash,k| hash[k] = 0 }
20
+ super
21
+ end
22
+
23
+ # Register a filter to write status information to the given stream. By
24
+ # default, a single line is used to report object counts while the dump is
25
+ # in progress; dump counts for each class are written when complete. The
26
+ # verbose and quiet options can be used to increase or decrease
27
+ # verbository.
28
+ #
29
+ # out - An IO object to write to, like stderr.
30
+ # verbose - Whether verbose output should be enabled.
31
+ # quiet - Whether quiet output should be enabled.
32
+ #
33
+ # Returns the Replicate::Status object.
34
+ def log_to(out=$stderr, verbose=false, quiet=false)
35
+ use Replicate::Status, 'load', out, verbose, quiet
36
+ end
37
+
38
+ # Feed a single replicant tuple into the loader.
39
+ #
40
+ # type - The class to create. Must respond to load_replicant.
41
+ # id - The remote system's id for this object.
42
+ # attrs - Hash of primitively typed objects.
43
+ #
44
+ # Returns the need object resulting from the load operation.
45
+ def feed(type, id, attrs)
46
+ type = type.to_s
47
+ object = load(type, id, attrs)
48
+ @stats[type] += 1
49
+ emit type, id, attrs, object
50
+ end
51
+
52
+ # Read multiple [type, id, attrs] replicant tuples from io and call the
53
+ # feed method to load and filter the object.
54
+ def read(io)
55
+ begin
56
+ while object = Marshal.load(io)
57
+ type, id, attrs = object
58
+ feed type, id, attrs
59
+ end
60
+ rescue EOFError
61
+ end
62
+ end
63
+
64
+ # Load an individual replicant into the underlying datastore.
65
+ #
66
+ # type - Model class name as a String.
67
+ # id - Primary key id of the record on the dump system. This must be
68
+ # translated to the local system and stored in the keymap.
69
+ # attrs - Hash of attributes to set on the new record.
70
+ #
71
+ # Returns the new object instance.
72
+ def load(type, id, attributes)
73
+ model_class = constantize(type)
74
+ translate_ids attributes
75
+ begin
76
+ new_id, instance = model_class.load_replicant(type, id, attributes)
77
+ rescue => boom
78
+ warn "error: loading #{type} #{id} #{boom.class} #{boom}"
79
+ raise
80
+ end
81
+ register_id instance, type, id, new_id
82
+ instance
83
+ end
84
+
85
+ # Translate remote system id references in the attributes hash to their
86
+ # local system id values. The attributes hash may include special id
87
+ # values like this:
88
+ # { 'title' => 'hello there',
89
+ # 'repository_id' => [:id, 'Repository', 1234],
90
+ # 'label_ids' => [:id, 'Label', [333, 444, 555, 666, ...]]
91
+ # ... }
92
+ # These values are translated to local system ids. All object
93
+ # references must be loaded prior to the referencing object.
94
+ def translate_ids(attributes)
95
+ attributes.each do |key, value|
96
+ next unless value.is_a?(Array) && value[0] == :id
97
+ type, value = value[1].to_s, value[2]
98
+ local_ids =
99
+ Array(value).map do |remote_id|
100
+ if local_id = @keymap[type][remote_id]
101
+ local_id
102
+ else
103
+ warn "error: #{type} #{remote_id} missing from keymap"
104
+ end
105
+ end
106
+ if value.is_a?(Array)
107
+ attributes[key] = local_ids
108
+ else
109
+ attributes[key] = local_ids[0]
110
+ end
111
+ end
112
+ end
113
+
114
+ # Register an id in the keymap. Every object loaded must be stored here so
115
+ # that key references can be resolved.
116
+ def register_id(object, type, remote_id, local_id)
117
+ @keymap[type.to_s][remote_id] = local_id
118
+ c = object.class
119
+ while !['Object', 'ActiveRecord::Base'].include?(c.name)
120
+ @keymap[c.name][remote_id] = local_id
121
+ c = c.superclass
122
+ end
123
+ end
124
+
125
+
126
+ # Turn a string into an object by traversing constants.
127
+ def constantize(string)
128
+ namespace = Object
129
+ string.split('::').each { |name| namespace = namespace.const_get(name) }
130
+ namespace
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,57 @@
1
+ module Replicate
2
+ # Simple OpenStruct style object that supports the dump and load protocols.
3
+ # Useful in tests and also when you want to dump an object that doesn't
4
+ # implement the dump and load methods.
5
+ #
6
+ # >> object = Replicate::Object.new :name => 'Joe', :age => 24
7
+ # >> object.age
8
+ # >> 24
9
+ # >> object.attributes
10
+ # { 'name' => 'Joe', 'age' => 24 }
11
+ #
12
+ class Object
13
+ attr_accessor :id
14
+ attr_accessor :attributes
15
+
16
+ def initialize(id=nil, attributes={})
17
+ attributes, id = id, nil if id.is_a?(Hash)
18
+ @id = id || self.class.generate_id
19
+ self.attributes = attributes
20
+ end
21
+
22
+ def attributes=(hash)
23
+ @attributes = {}
24
+ hash.each { |key, value| write_attribute key, value }
25
+ end
26
+
27
+ def [](key)
28
+ @attributes[key.to_s]
29
+ end
30
+
31
+ def []=(key, value)
32
+ @attributes[key.to_s] = value
33
+ end
34
+
35
+ def write_attribute(key, value)
36
+ meta = (class<<self;self;end)
37
+ meta.send(:define_method, key) { value }
38
+ meta.send(:define_method, "#{key}=") { |val| write_attribute(key, val) }
39
+ @attributes[key.to_s] = value
40
+ value
41
+ end
42
+
43
+ def dump_replicant(dumper)
44
+ dumper.write self.class, @id, @attributes, self
45
+ end
46
+
47
+ def self.load_replicant(type, id, attrs)
48
+ object = new(generate_id, attrs)
49
+ [object.id, object]
50
+ end
51
+
52
+ def self.generate_id
53
+ @last_id ||= 0
54
+ @last_id += 1
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ module Replicate
2
+ # Replicate filter that writes info to the console. Used by the Dumper and
3
+ # Loader to get basic console output.
4
+ class Status
5
+ def initialize(dumper, prefix, out, verbose=false, quiet=false)
6
+ @dumper = dumper
7
+ @prefix = prefix
8
+ @out = out
9
+ @verbose = verbose
10
+ @quiet = quiet
11
+ @count = 0
12
+ end
13
+
14
+ def call(type, id, attrs, object)
15
+ @count += 1
16
+ if @verbose
17
+ verbose_log type, id, attrs, object
18
+ elsif !@quiet
19
+ normal_log type, id, attrs, object
20
+ end
21
+ end
22
+
23
+ def verbose_log(type, id, attrs, object)
24
+ desc_attr = %w[name login email number title].find { |k| attrs.key?(k) }
25
+ desc = desc_attr ? attrs[desc_attr] : id
26
+ @out.puts "#{@prefix}: %-30s %s" % [type.sub('Replicate::', ''), desc]
27
+ end
28
+
29
+ def normal_log(type, id, attrs, object)
30
+ @out.write "==> #{@prefix}ing: #{@count} objects \r"
31
+ end
32
+
33
+ def complete
34
+ dump_stats if !@quiet
35
+ end
36
+
37
+ def dump_stats(stats=@dumper.stats.dup)
38
+ @out.puts "==> #{@prefix}ed #{@count} total objects: "
39
+ width = 0
40
+ stats.keys.each do |key|
41
+ class_name = format_class_name(key)
42
+ stats[class_name] = stats.delete(key)
43
+ width = class_name.size if class_name.size > width
44
+ end
45
+ stats.to_a.sort_by { |k,n| k }.each do |class_name, count|
46
+ @out.write "%-#{width + 1}s %5d\n" % [class_name, count]
47
+ end
48
+ end
49
+
50
+ def format_class_name(class_name)
51
+ class_name.sub(/Replicate::/, '')
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,253 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'active_record'
4
+ require 'replicate'
5
+
6
+ dbfile = File.expand_path('../db', __FILE__)
7
+ File.unlink dbfile if File.exist?(dbfile)
8
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => dbfile)
9
+
10
+ ActiveRecord::Migration.verbose = false
11
+ ActiveRecord::Schema.define do
12
+ create_table "users", :force => true do |t|
13
+ t.string "login"
14
+ t.datetime "created_at"
15
+ t.datetime "updated_at"
16
+ end
17
+
18
+ create_table "profiles", :force => true do |t|
19
+ t.integer "user_id"
20
+ t.string "name"
21
+ t.string "homepage"
22
+ end
23
+
24
+ create_table "emails", :force => true do |t|
25
+ t.integer "user_id"
26
+ t.string "email"
27
+ t.datetime "created_at"
28
+ end
29
+ end
30
+
31
+ class User < ActiveRecord::Base
32
+ has_one :profile, :dependent => :destroy
33
+ has_many :emails, :dependent => :destroy, :order => 'id'
34
+
35
+ replicate_natural_key :login
36
+ end
37
+
38
+ class Profile < ActiveRecord::Base
39
+ belongs_to :user
40
+
41
+ replicate_natural_key :user_id
42
+ end
43
+
44
+ class Email < ActiveRecord::Base
45
+ belongs_to :user
46
+
47
+ replicate_natural_key :user_id, :email
48
+ end
49
+
50
+ class ActiveRecordTest < Test::Unit::TestCase
51
+ def setup
52
+ self.class.fixtures
53
+ ActiveRecord::Base.connection.increment_open_transactions
54
+ ActiveRecord::Base.connection.begin_db_transaction
55
+
56
+ @rtomayko = User.find_by_login('rtomayko')
57
+ @kneath = User.find_by_login('kneath')
58
+ @tmm1 = User.find_by_login('tmm1')
59
+
60
+ User.replicate_associations = []
61
+
62
+ @dumper = Replicate::Dumper.new
63
+ @loader = Replicate::Loader.new
64
+ end
65
+
66
+ def teardown
67
+ ActiveRecord::Base.connection.rollback_db_transaction
68
+ ActiveRecord::Base.connection.decrement_open_transactions
69
+ end
70
+
71
+ def self.fixtures
72
+ return if @fixtures
73
+ @fixtures = true
74
+ user = User.create! :login => 'rtomayko'
75
+ user.create_profile :name => 'Ryan Tomayko', :homepage => 'http://tomayko.com'
76
+ user.emails.create! :email => 'ryan@github.com'
77
+ user.emails.create! :email => 'rtomayko@gmail.com'
78
+
79
+ user = User.create! :login => 'kneath'
80
+ user.create_profile :name => 'Kyle Neath', :homepage => 'http://warpspire.com'
81
+ user.emails.create! :email => 'kyle@github.com'
82
+
83
+ user = User.create! :login => 'tmm1'
84
+ user.create_profile :name => 'tmm1', :homepage => 'https://github.com/tmm1'
85
+ end
86
+
87
+ def test_extension_modules_loaded
88
+ assert User.respond_to?(:load_replicant)
89
+ assert User.new.respond_to?(:dump_replicant)
90
+ end
91
+
92
+ def test_auto_dumping_belongs_to_associations
93
+ objects = []
94
+ @dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }
95
+
96
+ rtomayko = User.find_by_login('rtomayko')
97
+ @dumper.dump rtomayko.profile
98
+
99
+ assert_equal 2, objects.size
100
+
101
+ type, id, attrs, obj = objects.shift
102
+ assert_equal 'User', type
103
+ assert_equal rtomayko.id, id
104
+ assert_equal 'rtomayko', attrs['login']
105
+ assert_equal rtomayko.created_at, attrs['created_at']
106
+ assert_equal rtomayko, obj
107
+
108
+ type, id, attrs, obj = objects.shift
109
+ assert_equal 'Profile', type
110
+ assert_equal rtomayko.profile.id, id
111
+ assert_equal 'Ryan Tomayko', attrs['name']
112
+ assert_equal rtomayko.profile, obj
113
+ end
114
+
115
+ def test_auto_dumping_has_one_associations
116
+ objects = []
117
+ @dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }
118
+
119
+ rtomayko = User.find_by_login('rtomayko')
120
+ @dumper.dump rtomayko
121
+
122
+ assert_equal 2, objects.size
123
+
124
+ type, id, attrs, obj = objects.shift
125
+ assert_equal 'User', type
126
+ assert_equal rtomayko.id, id
127
+ assert_equal 'rtomayko', attrs['login']
128
+ assert_equal rtomayko.created_at, attrs['created_at']
129
+ assert_equal rtomayko, obj
130
+
131
+ type, id, attrs, obj = objects.shift
132
+ assert_equal 'Profile', type
133
+ assert_equal rtomayko.profile.id, id
134
+ assert_equal 'Ryan Tomayko', attrs['name']
135
+ assert_equal [:id, 'User', rtomayko.id], attrs['user_id']
136
+ assert_equal rtomayko.profile, obj
137
+ end
138
+
139
+ def test_dumping_has_many_associations
140
+ objects = []
141
+ @dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }
142
+
143
+ User.replicate_associations :emails
144
+ rtomayko = User.find_by_login('rtomayko')
145
+ @dumper.dump rtomayko
146
+
147
+ assert_equal 4, objects.size
148
+
149
+ type, id, attrs, obj = objects.shift
150
+ assert_equal 'User', type
151
+ assert_equal rtomayko.id, id
152
+ assert_equal 'rtomayko', attrs['login']
153
+ assert_equal rtomayko.created_at, attrs['created_at']
154
+ assert_equal rtomayko, obj
155
+
156
+ type, id, attrs, obj = objects.shift
157
+ assert_equal 'Profile', type
158
+ assert_equal rtomayko.profile.id, id
159
+ assert_equal 'Ryan Tomayko', attrs['name']
160
+ assert_equal rtomayko.profile, obj
161
+
162
+ type, id, attrs, obj = objects.shift
163
+ assert_equal 'Email', type
164
+ assert_equal 'ryan@github.com', attrs['email']
165
+ assert_equal [:id, 'User', rtomayko.id], attrs['user_id']
166
+ assert_equal rtomayko.emails.first, obj
167
+
168
+ type, id, attrs, obj = objects.shift
169
+ assert_equal 'Email', type
170
+ assert_equal 'rtomayko@gmail.com', attrs['email']
171
+ assert_equal [:id, 'User', rtomayko.id], attrs['user_id']
172
+ assert_equal rtomayko.emails.last, obj
173
+ end
174
+
175
+ def test_loading_everything
176
+ objects = []
177
+ @dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }
178
+
179
+ # dump all users and associated objects and destroy
180
+ User.replicate_associations :emails
181
+ dumped_users = {}
182
+ %w[rtomayko kneath tmm1].each do |login|
183
+ user = User.find_by_login(login)
184
+ @dumper.dump user
185
+ user.destroy
186
+ dumped_users[login] = user
187
+ end
188
+ assert_equal 9, objects.size
189
+
190
+ # insert another record to ensure id changes for loaded records
191
+ sr = User.create!(:login => 'sr')
192
+ sr.create_profile :name => 'Simon Rozet'
193
+ sr.emails.create :email => 'sr@github.com'
194
+
195
+ # load everything back up
196
+ objects.each { |type, id, attrs, obj| @loader.feed type, id, attrs }
197
+
198
+ # verify attributes are set perfectly again
199
+ user = User.find_by_login('rtomayko')
200
+ assert_equal 'rtomayko', user.login
201
+ assert_equal dumped_users['rtomayko'].created_at, user.created_at
202
+ assert_equal dumped_users['rtomayko'].updated_at, user.updated_at
203
+ assert_equal 'Ryan Tomayko', user.profile.name
204
+ assert_equal 2, user.emails.size
205
+
206
+ # make sure everything was recreated
207
+ %w[rtomayko kneath tmm1].each do |login|
208
+ user = User.find_by_login(login)
209
+ assert_not_nil user
210
+ assert_not_nil user.profile
211
+ assert !user.emails.empty?, "#{login} has no emails" if login != 'tmm1'
212
+ end
213
+ end
214
+
215
+ def test_loading_with_existing_records
216
+ objects = []
217
+ @dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }
218
+
219
+ # dump all users and associated objects and destroy
220
+ User.replicate_associations :emails
221
+ dumped_users = {}
222
+ %w[rtomayko kneath tmm1].each do |login|
223
+ user = User.find_by_login(login)
224
+ user.profile.update_attribute :name, 'CHANGED'
225
+ @dumper.dump user
226
+ dumped_users[login] = user
227
+ end
228
+ assert_equal 9, objects.size
229
+
230
+ # load everything back up
231
+ objects.each { |type, id, attrs, obj| @loader.feed type, id, attrs }
232
+
233
+ # ensure additional objects were not created
234
+ assert_equal 3, User.count
235
+
236
+ # verify attributes are set perfectly again
237
+ user = User.find_by_login('rtomayko')
238
+ assert_equal 'rtomayko', user.login
239
+ assert_equal dumped_users['rtomayko'].created_at, user.created_at
240
+ assert_equal dumped_users['rtomayko'].updated_at, user.updated_at
241
+ assert_equal 'CHANGED', user.profile.name
242
+ assert_equal 2, user.emails.size
243
+
244
+ # make sure everything was recreated
245
+ %w[rtomayko kneath tmm1].each do |login|
246
+ user = User.find_by_login(login)
247
+ assert_not_nil user
248
+ assert_not_nil user.profile
249
+ assert_equal 'CHANGED', user.profile.name
250
+ assert !user.emails.empty?, "#{login} has no emails" if login != 'tmm1'
251
+ end
252
+ end
253
+ end