acts_as_replaceable 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []