Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
devwout committed Apr 26, 2010
0 parents commit 772900f
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 0 deletions.
3 changes: 3 additions & 0 deletions README
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
activerecord_merge : Simple merge for ActiveRecord objects and their associations

See http://ewout.name/2010/04/generic-deep-merge-for-activerecord.
94 changes: 94 additions & 0 deletions lib/merge.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
module Merge

# True if self is safe to merge with +object+, ie they are more or less equal.
# Default implementation compares all attributes except id and metadata.
# Can be overridden in specific models that have a neater way of comparison.
def merge_equal?(object)
object.instance_of?(self.class) and merge_attributes == object.merge_attributes
end

MERGE_INDIFFERENT_ATTRIBUTES = %w(id position created_at updated_at creator_id updater_id).freeze
MERGE_EXCLUDE_ASSOCIATIONS = [].freeze

# Attribute hash used for comparison.
def merge_attributes
merge_attribute_names.inject({}) do |attrs, name|
attrs[name] = read_attribute(name)
attrs
end
end

# Names of the attributes that should be merged.
def merge_attribute_names
attribute_names - MERGE_INDIFFERENT_ATTRIBUTES
end

# Names of associations excluded from the merge.
# Override if the model has multiple scoped associations,
# that can all be retrieved by a single has_many association.
def merge_exclude_associations
MERGE_EXCLUDE_ASSOCIATIONS
end

# Merge this object with the given +objects+.
# This object will serve as the master,
# blank attributes will be taken from the given objects, in order.
# All associations to +objects+ will be assigned to +self+.
def merge!(*objects)
transaction do
merge_attributes!(*objects)
merge_association_reflections.each do |r|
local = send(r.name)
objects.each do |object|
if r.macro == :has_one
other = object.send(r.name)
if local and other
local.merge!(other)
elsif other
send("#{r.name}=", other)
end
else
other = object.send(r.name) - local
# May be better to compare without the primary key attribute instead of setting it.
other.each {|o| o.write_attribute(r.primary_key_name, self.id)}
other.reject! {|o| local.any? {|l| merge_if_equal(l,o) }}
local << other
end
end
end
objects.each {|o| o.reload and o.destroy unless o.new_record?}
end
end

def merge_attributes!(*objects)
blank_attributes = merge_attribute_names.select {|att| read_attribute(att).blank?}
until blank_attributes.empty? or objects.empty?
object = objects.shift
blank_attributes.reject! do |att|
if val = object.read_attribute(att) and not val.blank?
write_attribute(att, val)
end
end
end
save!
end

private

def merge_association_reflections
self.class.reflect_on_all_associations.select do |r|
[:has_many, :has_one, :has_and_belongs_to_many].include?(r.macro) and
not r.options[:through] and
not merge_exclude_associations.include?(r.name.to_sym)
end
end

def merge_if_equal(master, object)
if master.merge_equal?(object)
master.merge!(object) ; true
end
end

end

ActiveRecord::Base.class_eval { include Merge }
6 changes: 6 additions & 0 deletions spec/database.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
adapter: mysql
database: activerecord_merge_test
username: root
password:
socket: /opt/local/var/run/mysql5/mysqld.sock
encoding: utf8
184 changes: 184 additions & 0 deletions spec/merge_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
require 'yaml'
require 'rubygems'
require 'activerecord'

spec_dir = File.dirname(__FILE__)
$LOAD_PATH.unshift spec_dir, File.join(spec_dir, '..', 'lib')

ActiveRecord::Base.establish_connection(YAML.load(File.read(File.join(spec_dir, "database.yml"))))

require 'schema'
require 'models'
require 'merge'

describe Merge do

it 'should ignore created_at and updated_at in merge_equal' do
c = Company.create!(:name => "Myname", :created_at => Date.civil(2008,1,10), :updated_at => Date.civil(2008,2,4))
c.should be_merge_equal(Company.create!(:name => "Myname"))
end

describe "attributes only" do

it 'should overwrite blank attributes' do
c1 = Company.create!(:name => "company1", :alpha => "")
c2 = Company.create!(:name => "company2", :alpha => "C2")
c3 = Company.create!(:name => "company3", :alpha => "C3", :status_code => 2)
c1.merge!(c2, c3)
c1.name.should == "company1"
c1.alpha.should == "C2"
c1.status_code.should == 2
Company.all(:conditions => {:id => [c2.id, c3.id]}).should == []
end

it 'should overwrite blank foreign keys' do
c = Company.create!
p1 = Project.create!(:name => "Website")
p2 = Project.create!(:name => "Site", :company => c)
p1.merge!(p2)
p1.name.should == "Website"
p1.company.should == c
end

it 'should keep existing foreign keys' do
c1 = Company.create!
c2 = Company.create!
p1 = Project.create!(:company => c1)
p2 = Project.create!(:company => c2)
p1.merge!(p2)
p1.company.should == c1
c2.projects.should be_empty
end

it 'should ignore creator and updater metadata' do
c1 = Company.create!
p1 = Person.create!
c2 = Company.create!(:creator => p1, :updater => p1)
c1.merge!(c2)
c1.creator.should be_nil
c1.updater.should be_nil
end

end

describe "has_many associations" do

it 'should associate all related objects to the master' do
c1 = Company.create!
c2 = Company.create!
c3 = Company.create!
p1 = Person.create!(:relationships => [Relationship.new(:company => c1)])
p2 = Person.create!(:relationships => [Relationship.new(:company => c2)])
p3 = Person.create!(:relationships => [Relationship.new(:company => c3)])
p4 = Person.create!(:relationships => [Relationship.new(:company => c3)])
c1.merge!(c2, c3)
c1.relationships.length.should == 4
c1.people.length.should == 4
Company.all(:conditions => {:id => [c2.id, c3.id]}).should == []
end

it 'should not associate objects that are merge_equal twice' do
c1 = Company.create!
c2 = Company.create!
p1 = Person.create!(:relationships => [Relationship.new(:company => c1)])
p2 = Person.create!(:relationships => [Relationship.new(:company => c2)])
p3 = Person.create!(:relationships => [Relationship.new(:company => c1), Relationship.new(:company => c2)])
c1.merge!(c2)
c1.relationships.length.should == 3
p3.reload.relationships.length.should == 1
p3.companies.should == [c1]
end

it 'should merge associated objects that are merge_equal' do
c1 = Company.create!
c2 = Company.create!
ph1 = Phonenumber.create!(:phonable => c1, :country_code => "32", :number => "123456")
ph2 = Phonenumber.create!(:phonable => c2, :country_code => "32", :number => "12/34.56", :description => "Home")
c1.merge!(c2)
c1.phonenumbers.length.should == 1
c1.phonenumbers.first.number.should == "123456"
c1.phonenumbers.first.description.should == "Home"
Phonenumber.first(:conditions => {:id => ph2.id}).should be_nil
end

end

describe "has_and_belongs_to_many associations" do

it 'should associate all related objects to the master' do
pr1 = Project.create!
pr2 = Project.create!
p1 = Person.create!(:projects => [pr1])
p2 = Person.create!(:projects => [pr2])
pr1.merge!(pr2)
pr1.people.length.should == 2
p2.projects.length.should == 1
Project.first(:conditions => {:id => pr2.id}).should be_nil
end

it 'should not associate the same object twice' do
p1 = Person.create!
p2 = Person.create!
pr1 = Project.create!(:people => [p1])
pr2 = Project.create!(:people => [p1, p2])
pr3 = Project.create!(:people => [p2])
p1.merge!(p2)
p1.projects.length.should == 3
pr2.reload.people.length.should == 1
Person.connection.select_value("select count(*) from people_projects where project_id = #{pr2.id}").should == '1'
Person.first(:conditions => {:id => p2.id}).should be_nil
end

end

describe "has_one associations" do

it 'should keep the master association when available' do
a = Address.create!
c1 = Company.create!(:address => a)
c2 = Company.create!
c1.merge!(c2)
c1.reload.address.should == a
end

it 'should overwrite the master association when blank' do
a = Address.create!
c1 = Company.create!
c2 = Company.create!(:address => a)
c1.merge!(c2)
c1.address.should == a
end

it 'should merge the associated objects' do
c1 = Company.create!(:address => Address.create!(:city => "Brussels", :zip => "1000"))
c2 = Company.create!(:address => Address.create!(:street => "Somestreet 1"))
c1.merge!(c2)
c1.reload.address.street.should == "Somestreet 1"
c1.address.city.should == "Brussels"
c1.address.zip.should == "1000"
end

end

describe "excluded associations" do

it 'should not overwrite the master association when blank' do
p1 = Person.create!
p2 = Person.create!(:avatar => Avatar.create!)
p1.merge!(p2)
p1.avatar.should be_nil
Avatar.first(:conditions => {:id => p2.avatar.id}).should be_nil
Person.first(:conditions => {:id => p2.id}).should be_nil
end

it 'should not merge the associated objects' do
p1 = Person.create!(:avatar => Avatar.create!(:url => "http://example.org"))
p2 = Person.create!(:avatar => Avatar.create!(:alt => "example"))
p1.merge!(p2)
p1.reload.avatar.alt.should be_nil
p1.avatar.url.should == "http://example.org"
end

end

end
54 changes: 54 additions & 0 deletions spec/models.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
class Company < ActiveRecord::Base
has_one :address, :as => :addressable, :dependent => :destroy
has_many :relationships, :dependent => :destroy
has_many :people, :through => :relationships
has_many :projects
has_many :phonenumbers, :as => :phonable, :dependent => :destroy
belongs_to :creator, :class_name => 'Person'
belongs_to :updater, :class_name => 'Person'
end

class Project < ActiveRecord::Base
belongs_to :company
has_and_belongs_to_many :people
end

class Relationship < ActiveRecord::Base
belongs_to :company
belongs_to :person
end

class Person < ActiveRecord::Base
has_one :avatar, :dependent => :destroy
has_one :address, :as => :addressable, :dependent => :destroy
has_many :relationships, :dependent => :destroy
has_many :companies, :through => :relationships
has_many :phonenumbers, :as => :phonable, :dependent => :destroy
has_and_belongs_to_many :projects

def merge_exclude_associations
[:avatar]
end
end

class Phonenumber < ActiveRecord::Base
belongs_to :phonable, :polymorphic => true

def flat_number
number.gsub(/[^0-9]/, '')
end

def merge_equal?(p)
p.is_a?(Phonenumber) and
country_code == p.country_code and
flat_number = p.flat_number
end
end

class Address < ActiveRecord::Base
belongs_to :addressable, :polymorphic => true
end

class Avatar < ActiveRecord::Base
belongs_to :person
end
Loading

0 comments on commit 772900f

Please sign in to comment.