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 2 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 @@ -251,18 +251,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 @@ -76,7 +77,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 @@ -87,16 +89,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 @@ -111,6 +127,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
18 changes: 15 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] = [] }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this does go forward, this should be a Hash so non Integer ids don't error.

@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)
translate_ids type, id, attributes
begin
new_id, instance = model_class.load_replicant(type, id, attributes)
if not local_id.nil?
new_id, instance = model_class.load_replicant(type, id, attributes, local_id)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the local_id here is going to break backward compatibility unfortunately. Existing programs that implement custom load_replicant method will raise an ArgumentError.

else
new_id, instance = model_class.load_replicant(type, id, attributes)
end
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 hash"
end
end
if value.is_a?(Array)
Expand All @@ -121,6 +128,11 @@ 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?
waiting_type, waiting_id, waiting_attributes = @wait[type][remote_id]
@wait[type].delete(remote_id)
load(waiting_type, waiting_id, waiting_attributes, @keymap[waiting_type][waiting_id])
end
end

# Turn a string into an object by traversing constants. Identical to
Expand Down
74 changes: 74 additions & 0 deletions test/active_record_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@
t.integer "notable_id"
t.string "notable_type"
end

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 @@ -87,6 +99,15 @@ class Note < ActiveRecord::Base
belongs_to :notable, :polymorphic => true
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 @@ -186,6 +207,59 @@ 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

# 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

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