replicate 1.0

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,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