forked from devwout/activerecord_merge
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 772900f
Showing
6 changed files
with
391 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.