diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99bb58e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +*.iml +Gemfile.lock diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..f26c95a --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'rspec', '~> 3.4.0' +gem 'activerecord', '~> 4.2.0' +gem 'mysql2', '~> 0.4.3' diff --git a/README b/README deleted file mode 100644 index 259b7b3..0000000 --- a/README +++ /dev/null @@ -1,3 +0,0 @@ -activerecord_merge : Simple merge for ActiveRecord objects and their associations - -See http://ewout.name/2010/04/generic-deep-merge-for-activerecord. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a7b45e --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# activerecord_merge +## Simple merge for ActiveRecord objects and their associations + +### Installation +``` +gem install activerecord_merge +``` + +### Usage in Rails +Create `config/initializers/activerecord_merge.rb` with the following line of code +``` +require 'merge' +``` + +### More info +See [http://ewout.name/2010/04/generic-deep-merge-for-activerecord](http://ewout.name/2010/04/generic-deep-merge-for-activerecord) \ No newline at end of file diff --git a/activerecord_merge.gemspec b/activerecord_merge.gemspec new file mode 100644 index 0000000..d61144c --- /dev/null +++ b/activerecord_merge.gemspec @@ -0,0 +1,12 @@ +Gem::Specification.new do |s| + s.name = 'activerecord_merge' + s.version = '1.0.0' + s.date = '2016-03-07' + s.summary = 'ActiveRecord object merge' + s.description = 'Simple merge for ActiveRecord objects and their associations.' + s.authors = ['Ewout Van Troostenberghe'] + s.files = ['lib/merge.rb'] + s.homepage = + 'http://rubygems.org/gems/activerecord_merge' + s.license = 'MIT' +end diff --git a/lib/merge.rb b/lib/merge.rb index 2df10f4..fcb378c 100644 --- a/lib/merge.rb +++ b/lib/merge.rb @@ -1,15 +1,14 @@ 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 + object.instance_of?(self.class) && 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| @@ -17,20 +16,20 @@ def merge_attributes 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. + + # 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+. + + # 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+. @@ -42,59 +41,60 @@ def merge!(*objects) objects.each do |object| if r.macro == :has_one other = object.send(r.name) - if local and other + if local && other local.merge!(other) elsif other send("#{r.name}=", other) end + # TODO: remove this condition to test has_and_belongs_to_many relations. This condition was only added to raise error + elsif r.macro == :has_and_belongs_to_many + p 'activerecord_merge: merging objects with has_and_belongs_to_many is currently broken' 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[r.foreign_key] = self.id} - other.reject! {|o| local.any? {|l| merge_if_equal(l,o) }} + other.each { |o| o[r.foreign_key] = 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?} + objects.each { |o| o.reload && o.destroy unless o.new_record? } end end - + def merge_attributes!(*objects) - blank_attributes = merge_attribute_names.select {|att| self[att].blank?} - until blank_attributes.empty? or objects.empty? + blank_attributes = merge_attribute_names.select { |att| self[att].blank? } + until blank_attributes.empty? || objects.empty? object = objects.shift blank_attributes.reject! do |att| - if val = object[att] and not val.blank? + if val = object[att] and !val.blank? self[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) + self.class.reflect_on_all_associations.select do |r| + [:has_many, :has_one, :has_and_belongs_to_many].include?(r.macro) && + !r.options[:through] && + !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 + master.merge!(object); true end end - end ActiveRecord::Base.class_eval { include Merge } # Compatibility with ActiveRecord 2.x class ActiveRecord::Reflection::AssociationReflection - alias :foreign_key :primary_key_name unless method_defined? :foreign_key + alias_method :foreign_key, :primary_key_name unless method_defined? :foreign_key end - diff --git a/spec/database.yml b/spec/database.yml index ccfa8c9..13d4c2e 100644 --- a/spec/database.yml +++ b/spec/database.yml @@ -1,6 +1,7 @@ -adapter: mysql +adapter: mysql2 +encoding: utf8 database: activerecord_merge_test username: root -password: -socket: /opt/local/var/run/mysql5/mysqld.sock -encoding: utf8 \ No newline at end of file +password: +host: localhost +port: 3306 diff --git a/spec/merge_spec.rb b/spec/merge_spec.rb index a5f21e8..65c8fd0 100644 --- a/spec/merge_spec.rb +++ b/spec/merge_spec.rb @@ -5,191 +5,180 @@ 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")))) +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")) + c = Company.create!(name: 'Myname', created_at: Date.civil(2008, 1, 10), updated_at: Date.civil(2008, 2, 4)) + expect(c).to be_merge_equal(Company.create!(name: 'Myname')) end - - describe "attributes only" do - + + 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 = 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 == [] + expect(c1.name).to eq('company1') + expect(c1.alpha).to eq('C2') + expect(c1.status_code).to eq(2) + expect(Company.where(id: [c2.id, c3.id])).to eq([]) end - + it 'should overwrite blank foreign keys' do c = Company.create! - p1 = Project.create!(:name => "Website") - p2 = Project.create!(:name => "Site", :company => c) + p1 = Project.create!(name: 'Website') + p2 = Project.create!(name: 'Site', company: c) p1.merge!(p2) - p1.name.should == "Website" - p1.company.should == c + expect(p1.name).to eq('Website') + expect(p1.company).to eq(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 = Project.create!(company: c1) + p2 = Project.create!(company: c2) p1.merge!(p2) - p1.company.should == c1 - c2.projects.should be_empty + expect(p1.company).to eq(c1) + expect(c2.projects).to be_empty end - + it 'should ignore creator and updater metadata' do c1 = Company.create! p1 = Person.create! - c2 = Company.create!(:creator => p1, :updater => p1) + c2 = Company.create!(creator: p1, updater: p1) c1.merge!(c2) - c1.creator.should be_nil - c1.updater.should be_nil + expect(c1.creator).to be_nil + expect(c1.updater).to be_nil end - end - - describe "has_many associations" do - + + 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)]) + 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 == [] + expect(c1.relationships.length).to eq(4) + expect(c1.people.length).to eq(4) + expect(Company.where(id: [c2.id, c3.id])).to eq([]) 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)]) + 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] + expect(c1.relationships.length).to eq(3) + expect(p3.reload.relationships.length).to eq(1) + expect(p3.companies).to eq([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") + 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 + expect(c1.phonenumbers.length).to eq(1) + expect(c1.phonenumbers.first.number).to eq('123456') + expect(c1.phonenumbers.first.description).to eq('Home') + expect(Phonenumber.find_by(id: ph2.id)).to be_nil end it 'should not merge associated objects that are not 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 => "123457") + ph1 = Phonenumber.create!(phonable: c1, country_code: '32', number: '123456') + ph2 = Phonenumber.create!(phonable: c2, country_code: '32', number: '123457') c1.merge!(c2) - c1.phonenumbers.length.should == 2 - c1.phonenumbers.first.number.should == "123456" - c1.phonenumbers.last.number.should == "123457" + expect(c1.phonenumbers.length).to eq(2) + expect(c1.phonenumbers.first.number).to eq('123456') + expect(c1.phonenumbers.last.number).to eq('123457') end - end - - describe "has_and_belongs_to_many associations" do - + + describe 'has_and_belongs_to_many associations' do + # TODO: Fix merge!. It doesn't seem to support the has_and_belongs_to_many association 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]) + 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 + expect(pr1.people.length).to eq(2) + expect(p2.projects.length).to eq(1) + expect(Project.first(conditions: { id: pr2.id })).to 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]) + 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}").to_i.should == 1 - Person.first(:conditions => {:id => p2.id}).should be_nil + expect(p1.projects.length).to eq(3) + expect(pr2.reload.people.length).to eq(1) + expect(Person.connection.select_value("select count(*) from people_projects where project_id = #{pr2.id}").to_i).to eq(1) + expect(Person.first(conditions: { id: p2.id })).to be_nil end - end - - describe "has_one associations" do - + + describe 'has_one associations' do it 'should keep the master association when available' do a = Address.create! - c1 = Company.create!(:address => a) + c1 = Company.create!(address: a) c2 = Company.create! c1.merge!(c2) - c1.reload.address.should == a + expect(c1.reload.address).to eq(a) end - + it 'should overwrite the master association when blank' do a = Address.create! c1 = Company.create! - c2 = Company.create!(:address => a) + c2 = Company.create!(address: a) c1.merge!(c2) - c1.address.should == a + expect(c1.address).to eq(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 = 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" + expect(c1.reload.address.street).to eq('Somestreet 1') + expect(c1.address.city).to eq('Brussels') + expect(c1.address.zip).to eq('1000') end - end - - describe "excluded associations" do - + + describe 'excluded associations' do it 'should not overwrite the master association when blank' do p1 = Person.create! - p2 = Person.create!(:avatar => Avatar.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 + expect(p1.avatar).to be_nil + expect(Avatar.find_by(id: p2.avatar.id)).to be_nil + expect(Person.find_by(id: p2.id)).to 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 = 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" + expect(p1.reload.avatar.alt).to be_nil + expect(p1.avatar.url).to eq('http://example.org') end - end - end diff --git a/spec/models.rb b/spec/models.rb index 2ea0b66..774f6f3 100644 --- a/spec/models.rb +++ b/spec/models.rb @@ -1,11 +1,11 @@ class Company < ActiveRecord::Base - has_one :address, :as => :addressable, :dependent => :destroy - has_many :relationships, :dependent => :destroy - has_many :people, :through => :relationships + 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' + has_many :phonenumbers, as: :phonable, dependent: :destroy + belongs_to :creator, class_name: 'Person' + belongs_to :updater, class_name: 'Person' end class Project < ActiveRecord::Base @@ -19,34 +19,34 @@ class Relationship < ActiveRecord::Base 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_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 - + 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 + p.is_a?(Phonenumber) && + country_code == p.country_code && + flat_number == p.flat_number end end class Address < ActiveRecord::Base - belongs_to :addressable, :polymorphic => true + belongs_to :addressable, polymorphic: true end class Avatar < ActiveRecord::Base diff --git a/spec/schema.rb b/spec/schema.rb index 330d6a9..f1d48b9 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -1,50 +1,48 @@ ActiveRecord::Schema.define do - - create_table :companies, :force => true do |t| + create_table :companies, force: true do |t| t.string :name t.string :alpha t.integer :status_code - t.timestamps + t.timestamps null: true t.integer :creator_id, :updater_id end - - create_table :projects, :force => true do |t| + + create_table :projects, force: true do |t| t.belongs_to :company t.string :name end - - create_table :people_projects, :force => true, :id => false do |t| + + create_table :people_projects, force: true, id: false do |t| t.belongs_to :project t.belongs_to :person end - - create_table :relationships, :force => true do |t| + + create_table :relationships, force: true do |t| t.belongs_to :company t.belongs_to :person end - - create_table :people, :force => true do |t| + + create_table :people, force: true do |t| t.belongs_to :company t.string :first_name t.string :last_name end - - create_table :phonenumbers, :force => true do |t| - t.belongs_to :phonable, :polymorphic => true + + create_table :phonenumbers, force: true do |t| + t.belongs_to :phonable, polymorphic: true t.string :description t.string :country_code t.string :number end - - create_table :addresses, :force => true do |t| - t.belongs_to :addressable, :polymorphic => true + + create_table :addresses, force: true do |t| + t.belongs_to :addressable, polymorphic: true t.string :street, :zip, :city end - - create_table :avatars, :force => true do |t| + + create_table :avatars, force: true do |t| t.belongs_to :person t.string :url t.string :alt end - -end \ No newline at end of file +end