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 +5 -0
- data/lib/acts_as_replaceable/acts_as_replaceable.rb +115 -0
- data/lib/acts_as_replaceable.rb +3 -0
- data/spec/acts_as_replaceable_spec.rb +96 -0
- data/spec/spec_helper.rb +57 -0
- metadata +67 -0
data/README.rdoc
ADDED
@@ -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,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
|
data/spec/spec_helper.rb
ADDED
@@ -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: []
|