acts_as_replaceable 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,5 @@
1
+ == ActsAsReplaceable
2
+
3
+ Overloads the create_or_update_without_callbacks method to allow duplicate records to be replaced
4
+ without needing to always use find_or_create_by.
5
+
@@ -0,0 +1,115 @@
1
+ module ActsAsReplaceable
2
+ module ActMethod
3
+ # If any before_save methods change the attributes,
4
+ # acts_as_replaceable will not function correctly.
5
+ #
6
+ # OPTIONS
7
+ # :match => what fields to match against when finding a duplicate
8
+ # :insensitive_match => what fields to do case insensitive matching on.
9
+ # :inherit => what attributes of the existing record overwrite our own attributes
10
+ def acts_as_replaceable(options = {})
11
+ include ActsAsReplaceable::InstanceMethods
12
+
13
+ options.symbolize_keys!
14
+ cattr_accessor :acts_as_replaceable_options
15
+ self.acts_as_replaceable_options = {}
16
+ self.acts_as_replaceable_options[:match] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:match])
17
+ self.acts_as_replaceable_options[:insensitive_match] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:insensitive_match])
18
+ self.acts_as_replaceable_options[:inherit] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:inherit], options[:insensitive_match], :id, :created_at, :updated_at)
19
+ end
20
+ end
21
+
22
+ module HelperMethods
23
+ def self.sanitize_attribute_names(klass, *args)
24
+ # Intersect the proposed attributes with the column names so we don't start assigning attributes that don't exist. e.g. if the model doesn't have timestamps
25
+ klass.column_names & args.flatten.compact.collect(&:to_s)
26
+ end
27
+ end
28
+
29
+ module InstanceMethods
30
+ # Override the create or update method so we can run callbacks, but opt not to save if we don't need to
31
+ def create_record(*args)
32
+ find_and_replace
33
+ if @has_not_changed
34
+ logger.info "(acts_as_replaceable) Found unchanged #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
35
+ elsif @has_been_replaced
36
+ update_record(*args)
37
+ logger.info "(acts_as_replaceable) Updated existing #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
38
+ else
39
+ super
40
+ logger.info "(acts_as_replaceable) Created #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
41
+ end
42
+
43
+ return true
44
+ end
45
+
46
+ def find_and_replace
47
+ replace(find_duplicate)
48
+ end
49
+
50
+ private
51
+
52
+ def find_duplicate
53
+ records = self.class.where(match_conditions).where(insensitive_match_conditions)
54
+ if records.load.size > 1
55
+ raise "#{records.count} Duplicate #{self.class.model_name.pluralize} Present in Database:\n #{self.inspect} == #{records.inspect}"
56
+ end
57
+
58
+ return records.first
59
+ end
60
+
61
+ def replace(other)
62
+ return unless other
63
+ inherit_attributes(other)
64
+ @has_been_replaced = true
65
+ define_singleton_method(:new_record?) { false }
66
+ define_singleton_method(:persisted?) { true }
67
+ @has_not_changed = !mark_changes(other)
68
+ end
69
+
70
+ # Inherit other's attributes for those in acts_as_replaceable_options[:inherit]
71
+ def inherit_attributes(other)
72
+ acts_as_replaceable_options[:inherit].each do |attrib|
73
+ self[attrib] = other[attrib]
74
+ end
75
+ end
76
+
77
+ def mark_changes(other)
78
+ attribs = self.attributes
79
+
80
+ # Copy attributes to other and see how it would change if we updated it
81
+ # Mark all self's attributes that have changed, so even if they are
82
+ # still default values, they will be saved to the database
83
+ attribs.each do |key, value|
84
+ other[key] = value
85
+ end
86
+
87
+ other.changed.each {|attribute| send("#{attribute}_will_change!") }
88
+
89
+ return other.changed?
90
+ end
91
+
92
+ # Search the incoming attributes for attributes that are in the replaceable conditions and use those to form an Find conditions
93
+ def match_conditions
94
+ output = {}
95
+ acts_as_replaceable_options[:match].each do |attribute_name|
96
+ output[attribute_name] = self[attribute_name]
97
+ end
98
+ return output
99
+ end
100
+
101
+ def insensitive_match_conditions
102
+ sql = []
103
+ binds = []
104
+ acts_as_replaceable_options[:insensitive_match].each do |attribute_name|
105
+ if value = self[attribute_name]
106
+ sql << "LOWER(#{attribute_name}) = ?"
107
+ binds << self[attribute_name].downcase
108
+ else
109
+ sql << "#{attribute_name} IS NULL"
110
+ end
111
+ end
112
+ return [sql.join(' AND ')] + binds
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,3 @@
1
+ require 'acts_as_replaceable/acts_as_replaceable'
2
+
3
+ ActiveRecord::Base.extend ActsAsReplaceable::ActMethod
@@ -0,0 +1,96 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'acts_as_dag' do
4
+ before(:each) do
5
+ [Material, Item, Person].each(&:destroy_all) # Because we're using sqlite3 and it doesn't support transactional specs (afaik)
6
+ end
7
+
8
+ describe "A saved record" do
9
+
10
+ it "should raise an exception if more than one duplicate exists in the database" do
11
+ insert_model(Material, :name => 'wood')
12
+ insert_model(Material, :name => 'wood')
13
+ lambda {Material.create! :name => 'wood'}.should raise_exception
14
+ end
15
+
16
+ it "should raise an exception when matching against multiple fields" do
17
+ insert_model(Item, :identification_number => '1234', :holding_institution_id => 1)
18
+ insert_model(Item, :identification_number => '1234', :holding_institution_id => 1)
19
+ lambda {Item.create! :identification_number => '1234', :holding_institution_id => 1}.should raise_exception
20
+ end
21
+
22
+ it "should replace itself with an existing record by matching a single column" do
23
+ Material.create! :name => 'wood'
24
+ Material.create! :name => 'wood'
25
+ Material.where(:name => 'wood').count.should == 1
26
+ end
27
+
28
+ it "should replace itself with an existing record by matching multiple columns" do
29
+ Location.create! :country => 'Canada', :city => 'Vancouver'
30
+ Location.create! :country => 'Canada', :city => 'Vancouver'
31
+ Location.where(:country => 'Canada', :city => 'Vancouver').count.should == 1
32
+ end
33
+
34
+ it "should replace itself with an existing record by matching multiple columns and inheriting a column from the existing record" do
35
+ a = Item.create! :name => 'Stick', :identification_number => '1234', :holding_institution_id => 1, :collection_id => 2, :fingerprint => 'asdf'
36
+ b = Item.create! :name => 'Stick', :identification_number => '1234', :holding_institution_id => 1, :collection_id => 2
37
+ Item.where(:identification_number => '1234', :holding_institution_id => 1, :collection_id => 2).count.should == 1
38
+ b.fingerprint.should == 'asdf'
39
+ end
40
+
41
+ it "should update the non-match, non-inherit fields of the existing record" do
42
+ a = Item.create! :name => 'Stick', :identification_number => '1234', :holding_institution_id => 1, :collection_id => 2, :fingerprint => 'asdf'
43
+ b = Item.create! :name => 'Dip Stick', :identification_number => '1234', :holding_institution_id => 1, :collection_id => 2
44
+ c = Item.where(:identification_number => '1234', :holding_institution_id => 1, :collection_id => 2)
45
+ c.count.should == 1
46
+ c.first.name.should == 'Dip Stick'
47
+ end
48
+
49
+ it "should correctly replace an existing record when a match value is nil" do
50
+ a = Item.create! :name => 'Stick', :identification_number => '1234', :holding_institution_id => 1
51
+ b = Item.create! :name => 'Dip Stick', :identification_number => '1234', :holding_institution_id => 1
52
+ Item.where(:identification_number => '1234', :holding_institution_id => 1).count.should == 1
53
+ end
54
+
55
+ it "should replace itself with an existing record by performing case-insensitive matching on multiple columns" do
56
+ Person.create! :first_name => 'John', :last_name => 'Doe'
57
+ Person.create! :first_name => 'joHn', :last_name => 'doE'
58
+ Person.where(:first_name => 'John', :last_name => 'Doe').count.should == 1
59
+
60
+ Person.create! :first_name => 'Alanson', :last_name => 'Skinner'
61
+ Person.create! :first_name => 'Alanson', :last_name => 'Skinner'
62
+ Person.where(:first_name => 'Alanson', :last_name => 'Skinner').count.should == 1
63
+ end
64
+
65
+ it "should not replace an existing record with fields that were used to match" do
66
+ Person.create! :first_name => 'joHn', :last_name => 'doE'
67
+ Person.create! :first_name => 'John', :last_name => 'Doe'
68
+ Person.where(:first_name => 'joHn', :last_name => 'doE').count.should == 1
69
+ Person.where(:first_name => 'John', :last_name => 'Doe').count.should == 0
70
+ end
71
+
72
+ it "should correctly replace an existing record when an insensitive-match value is nil" do
73
+ a = Person.create! :first_name => 'John'
74
+ a = Person.create! :first_name => 'John'
75
+ Person.where(:first_name => 'John').count.should == 1
76
+ end
77
+
78
+ it "should inherit the id of the existing record" do
79
+ a = Material.create! :name => 'wood'
80
+ b = Material.create! :name => 'wood'
81
+ b.id.should == a.id
82
+ end
83
+
84
+ it "should not be a new_record? if it has replaced an existing record" do
85
+ a = Material.create! :name => 'wood'
86
+ b = Material.create! :name => 'wood'
87
+ b.new_record?.should be_false
88
+ end
89
+
90
+ it "should be persisted? if it has replaced an existing record" do
91
+ a = Material.create! :name => 'wood'
92
+ b = Material.create! :name => 'wood'
93
+ b.persisted?.should be_true
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
2
+ require 'active_record'
3
+ require 'logger'
4
+ require 'acts_as_replaceable'
5
+
6
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
7
+ ActiveRecord::Base.logger.level = Logger::INFO
8
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
9
+
10
+ ActiveRecord::Schema.define(:version => 0) do
11
+ create_table :items, :force => true do |t|
12
+ t.integer :holding_institution_id
13
+ t.string :identification_number
14
+ t.integer :collection_id
15
+ t.string :name
16
+ t.string :fingerprint
17
+ end
18
+
19
+ create_table :people, :force => true do |t|
20
+ t.string :first_name
21
+ t.string :last_name
22
+ end
23
+
24
+ create_table :materials, :force => true do |t|
25
+ t.string :name
26
+ end
27
+
28
+ create_table :locations, :force => true do |t|
29
+ t.string :country
30
+ t.string :city
31
+ end
32
+ end
33
+
34
+
35
+ class Material < ActiveRecord::Base
36
+ acts_as_replaceable :match => :name
37
+ validates_presence_of :name
38
+ end
39
+
40
+ class Location < ActiveRecord::Base
41
+ acts_as_replaceable :match => [:country, :city]
42
+ validates_presence_of :country, :city
43
+ end
44
+
45
+ class Item < ActiveRecord::Base
46
+ acts_as_replaceable :match => [:holding_institution_id, :identification_number, :collection_id], :inherit => :fingerprint
47
+ validates_presence_of :holding_institution_id, :identification_number
48
+ end
49
+
50
+ class Person < ActiveRecord::Base
51
+ acts_as_replaceable :insensitive_match => [:first_name, :last_name]
52
+ validates_presence_of :first_name
53
+ end
54
+
55
+ def insert_model(klass, attributes)
56
+ ActiveRecord::Base.connection.execute "INSERT INTO #{klass.quoted_table_name} (#{attributes.keys.join(",")}) VALUES (#{attributes.values.collect { |value| ActiveRecord::Base.connection.quote(value) }.join(",")})", 'Fixture Insert'
57
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_replaceable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Nicholas Jakobsen
9
+ - Ryan Wallace
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2013-09-03 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rails
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ~>
21
+ - !ruby/object:Gem::Version
22
+ version: '4.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ~>
29
+ - !ruby/object:Gem::Version
30
+ version: '4.0'
31
+ description:
32
+ email: technical@rrnpilot.org
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - lib/acts_as_replaceable/acts_as_replaceable.rb
38
+ - lib/acts_as_replaceable.rb
39
+ - spec/acts_as_replaceable_spec.rb
40
+ - spec/spec_helper.rb
41
+ - README.rdoc
42
+ homepage: http://github.com/rrn/acts_as_replaceable
43
+ licenses: []
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - '>='
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubyforge_project:
62
+ rubygems_version: 1.8.25
63
+ signing_key:
64
+ specification_version: 3
65
+ summary: Overloads the create_or_update_without_callbacks method to allow duplicate
66
+ records to be replaced without needing to always use find_or_create_by.
67
+ test_files: []