Skip to content
This repository was archived by the owner on Nov 9, 2017. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions lib/replicate/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,20 @@ def replicate_omit_attributes=(attribute_names)
# id - Primary key id of the record on the dump system. This must be
# translated to the local system and stored in the keymap.
# attrs - Hash of attributes to set on the new record.
# local_id - to reload an object with given local id
#
# Returns the ActiveRecord object instance for the new record.
def load_replicant(type, id, attributes)
instance = replicate_find_existing_record(attributes) || new
def load_replicant(type, id, attributes, local_id = nil)
instance = replicate_find_existing_record(attributes, local_id) || new
create_or_update_replicant instance, attributes
end

# Locate an existing record using the replicate_natural_key attribute
# values.
#
# Returns the existing record if found, nil otherwise.
def replicate_find_existing_record(attributes)
def replicate_find_existing_record(attributes, id = nil)
return find_by_id(id) if not id.nil? and not find_by_id(id).nil?
return if replicate_natural_key.empty?
conditions = {}
replicate_natural_key.each do |attribute_name|
Expand Down
25 changes: 21 additions & 4 deletions lib/replicate/dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Dumper < Emitter
# block - Dump context block. If given, the end of the block's execution
# is assumed to be the end of the dump stream.
def initialize(io=nil)
@seen = Hash.new { |hash,k| hash[k] = {} }
@memo = Hash.new { |hash,k| hash[k] = {} }
super() do
marshal_to io if io
Expand Down Expand Up @@ -79,7 +80,8 @@ def dump(*objects)
end
objects = objects[0] if objects.size == 1 && objects[0].respond_to?(:to_ary)
objects.each do |object|
next if object.nil? || dumped?(object)
next if object.nil? || dumped?(object) || seen?(object)
see!(object)
if object.respond_to?(:dump_replicant)
args = [self]
args << opts unless object.method(:dump_replicant).arity == 1
Expand All @@ -90,16 +92,30 @@ def dump(*objects)
end
end

# Check if object has been written yet.
def dumped?(object)
# type and id helper
def type_and_id(object)
if object.respond_to?(:replicant_id)
type, id = object.replicant_id
elsif object.is_a?(Array)
type, id = object
else
return false
end
@memo[type.to_s][id]
yield type, id
end

# Check if object has been written yet.
def dumped?(object)
type_and_id(object) { |type,id| @memo[type.to_s][id] }
end

# Check if object has been seen yet (needed for loop prevention)
def seen?(object)
type_and_id(object) { |type,id| @seen[type.to_s][id] }
end

def see!(object)
type_and_id(object) { |type,id| @seen[type.to_s][id] = true }
end

# Called exactly once per unique type and id. Emits to all listeners.
Expand All @@ -114,6 +130,7 @@ def write(type, id, attributes, object)
type = type.to_s
return if dumped?([type, id])
@memo[type][id] = true
@seen[type].delete(id)

emit type, id, attributes, object
end
Expand Down
20 changes: 17 additions & 3 deletions lib/replicate/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Loader < Emitter

def initialize
@keymap = Hash.new { |hash,k| hash[k] = {} }
@wait = Hash.new { |hash,k| hash[k] = Hash.new { |hash2,k2| hash2[k2] = [] } }
@stats = Hash.new { |hash,k| hash[k] = 0 }
super
end
Expand Down Expand Up @@ -67,13 +68,18 @@ def read(io)
# id - Primary key id of the record on the dump system. This must be
# translated to the local system and stored in the keymap.
# attrs - Hash of attributes to set on the new record.
# local_id - to reload an object with given local id
#
# Returns the new object instance.
def load(type, id, attributes)
def load(type, id, attributes, local_id = nil)
model_class = constantize(type)
if not local_id.nil? and model_class.method(:load_replicant).arity != -4
warn "cannot reload #{type}(#{id}) since #{type}#load_replicant overriden and does not provide reload functionality"
return
end
translate_ids type, id, attributes
begin
new_id, instance = model_class.load_replicant(type, id, attributes)
new_id, instance = model_class.load_replicant(*([type, id, attributes, local_id].compact))
rescue => boom
warn "error: loading #{type} #{id} #{boom.class} #{boom}"
raise
Expand All @@ -100,8 +106,9 @@ def translate_ids(type, id, attributes)
if local_id = @keymap[referenced_type][remote_id]
local_id
else
@wait[referenced_type][remote_id] << [type, id, attributes.clone()]
warn "warn: #{referenced_type}(#{remote_id}) not in keymap, " +
"referenced by #{type}(#{id})##{key}"
"referenced by #{type}(#{id})##{key}, added to wait list"
end
end
if value.is_a?(Array)
Expand All @@ -121,6 +128,13 @@ def register_id(object, type, remote_id, local_id)
@keymap[c.name][remote_id] = local_id
c = c.superclass
end
if not @wait[type][remote_id].nil?
@wait[type][remote_id].each do |waiting_object|
waiting_type, waiting_id, waiting_attributes = waiting_object
load(waiting_type, waiting_id, waiting_attributes, @keymap[waiting_type][waiting_id])
end
@wait[type].delete(remote_id)
end
end

# Turn a string into an object by traversing constants. Identical to
Expand Down
109 changes: 108 additions & 1 deletion test/active_record_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,20 @@
t.integer "notable_id"
t.string "notable_type"
end

create_table "namespaced", :force => true

create_table "orders", :force => true do |t|
t.string "name"
t.integer "last_state_id"
end

create_table "states", :force => true do |t|
t.string "name"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "order_id"
end
end

# models
Expand Down Expand Up @@ -94,6 +106,15 @@ class User::Namespaced < ActiveRecord::Base
self.table_name = "namespaced"
end

class Order < ActiveRecord::Base
has_many :states
belongs_to :last_state, :class_name => 'State'
end

class State < ActiveRecord::Base
belongs_to :order
end

# The test case loads some fixture data once and uses transaction rollback to
# reset fixture state for each test's setup.
class ActiveRecordTest < Test::Unit::TestCase
Expand Down Expand Up @@ -193,6 +214,92 @@ def test_omit_dumping_of_association
type, id, attrs, obj = objects.shift
assert_equal 'User', type
end

def test_dump_and_load_correctly_despite_association_cycle
objects = []
@dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }

Order.replicate_associations :states
o = Order.create! :name => 'beer'
s1 = State.create! :name => 'ordered', :order => o
s2 = State.create! :name => 'paid', :order => o
o.last_state = s2
o.save!

@dumper.dump o

assert_equal 3, objects.size

# last state is resolved by Order belongs_to and is dumped first
type, id, attrs, obj = objects[0]
assert_equal 'State', type
assert_equal 2, attrs['id']
assert_equal [:id, "Order", 1], attrs['order_id']

# next comes the order itself
type, id, attrs, obj = objects[1]
assert_equal 'Order', type
assert_equal 1, attrs['id']
assert_equal [:id, "State", 2], attrs['last_state_id']

# and finally the has_many states, in this case just one left
type, id, attrs, obj = objects[2]
assert_equal 'State', type
assert_equal 1, attrs['id']
assert_equal [:id, "Order", 1], attrs['order_id']

# destroy objects
State.delete_all
Order.delete_all

assert_equal 0, Order.all.count
assert_equal 0, State.all.count

assert_equal 3, objects.size

# make more than one object to wait for referenced object
objects[1], objects[2] = objects[2], objects[1]

# restore dump
objects.each { |type, id, attrs, obj| @loader.feed type, id, attrs }

assert_equal 1, Order.all.count
assert_equal 2, State.all.count
# all states correctly linked to order?
assert_equal false, State.all.map {|s| s.order == Order.first}.include?(false)
# order linked to existing last_state?
assert_equal true, State.all.include?(Order.first.last_state)
end

def test_dump_and_load_with_overridden_load_replicant_method_if_association_cycle
objects = []
@dumper.listen { |type, id, attrs, obj| objects << [type, id, attrs, obj] }

(class << State; self end).class_eval do
define_method(:load_replicant) do |type, id, attrs|
super type, id, attrs
end
end

Order.replicate_associations :states
o = Order.create! :name => 'beer'
s1 = State.create! :name => 'ordered', :order => o
s2 = State.create! :name => 'paid', :order => o
o.last_state = s2
o.save!

@dumper.dump o

# destroy objects
State.delete_all
Order.delete_all

# restore dump
objects.each { |type, id, attrs, obj| @loader.feed type, id, attrs }

assert_equal 1, Order.all.count
assert_equal 2, State.all.count
end

if ActiveRecord::VERSION::STRING[0, 3] > '2.2'
def test_dump_and_load_non_standard_foreign_key_association
Expand Down