acts_as_replaceable 1.1.1 → 1.2.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0abcc21c226084d644f147bee703c5ee0f20cc34
4
+ data.tar.gz: ed37e53487557256201403234bb70c6f88750453
5
+ SHA512:
6
+ metadata.gz: 01782def5552b482d0fdbc2d60a3110dfa207969dbe981459e3392b7d88fe6604858826b3a1a24e76c2635645f7283c239b922974284ba06f32cad8ff742dc27
7
+ data.tar.gz: 59758d67d4d29d6ec6ee76ebeb54436ebc395c51a0439c750ec9716d88d855d924392ea42b4eca42051f12353f789042fd5c12770fc00eb19db59741f114cb87
@@ -1,7 +1,7 @@
1
- module ActsAsReplaceable
2
- class RecordNotUnique < Exception
3
- end
1
+ require 'digest'
2
+ require 'timeout'
4
3
 
4
+ module ActsAsReplaceable
5
5
  module ActMethod
6
6
  # If any before_save methods change the attributes,
7
7
  # acts_as_replaceable will not function correctly.
@@ -11,107 +11,176 @@ module ActsAsReplaceable
11
11
  # :insensitive_match => what fields to do case insensitive matching on.
12
12
  # :inherit => what attributes of the existing record overwrite our own attributes
13
13
  def acts_as_replaceable(options = {})
14
+ extend ActsAsReplaceable::ClassMethods
14
15
  include ActsAsReplaceable::InstanceMethods
15
16
 
16
- options.symbolize_keys!
17
+ attr_reader :has_been_replaced
17
18
  cattr_accessor :acts_as_replaceable_options
19
+
20
+ options.symbolize_keys!
18
21
  self.acts_as_replaceable_options = {}
19
22
  self.acts_as_replaceable_options[:match] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:match])
20
23
  self.acts_as_replaceable_options[:insensitive_match] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:insensitive_match])
21
24
  self.acts_as_replaceable_options[:inherit] = ActsAsReplaceable::HelperMethods.sanitize_attribute_names(self, options[:inherit], options[:insensitive_match], :id, :created_at, :updated_at)
25
+
26
+ if ActsAsReplaceable.concurrency && !Rails.cache.respond_to?(:increment)
27
+ raise LockingUnavailable, "To run ActsAsReplaceable in concurrency mode, the Rails cache must provide an :increment method that performs an atomic addition to the given key, e.g. Memcached"
28
+ end
22
29
  end
23
30
  end
24
31
 
32
+ # If using parallel processes to save replaceable records, set this to true to prevent race conditions
33
+ def self.concurrency=(value)
34
+ @concurrency = value
35
+ end
36
+
37
+ def self.concurrency
38
+ !!@concurrency
39
+ end
40
+
25
41
  module HelperMethods
26
42
  def self.sanitize_attribute_names(klass, *args)
27
43
  # 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
28
44
  klass.column_names & args.flatten.compact.collect(&:to_s)
29
45
  end
30
- end
31
46
 
32
- module InstanceMethods
33
- # Override the create or update method so we can run callbacks, but opt not to save if we don't need to
34
- def create_record(*args)
35
- find_and_replace
36
- if @has_not_changed
37
- logger.info "(acts_as_replaceable) Found unchanged #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
38
- elsif @has_been_replaced
39
- update_record(*args)
40
- logger.info "(acts_as_replaceable) Updated existing #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
41
- else
42
- super
43
- logger.info "(acts_as_replaceable) Created #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
47
+ # Search the incoming attributes for attributes that are in the replaceable conditions and use those to form an Find conditions
48
+ def self.match_conditions(record)
49
+ output = {}
50
+ record.acts_as_replaceable_options[:match].each do |attribute_name|
51
+ output[attribute_name] = record[attribute_name]
44
52
  end
45
-
46
- return true
53
+ return output
47
54
  end
48
55
 
49
- def find_and_replace
50
- replace(find_duplicate)
56
+ def self.insensitive_match_conditions(record)
57
+ sql = []
58
+ binds = []
59
+ record.acts_as_replaceable_options[:insensitive_match].each do |attribute_name|
60
+ if value = record[attribute_name]
61
+ sql << "LOWER(#{attribute_name}) = ?"
62
+ binds << record[attribute_name].downcase
63
+ else
64
+ sql << "#{attribute_name} IS NULL"
65
+ end
66
+ end
67
+ return [sql.join(' AND ')] + binds
51
68
  end
52
69
 
53
- private
70
+ # Copy attributes to existing and see how it would change if we updated it
71
+ # Mark all record's attributes that have changed, so even if they are
72
+ # still default values, they will be saved to the database
73
+ def self.mark_changes(record, existing)
74
+ copy_attributes(record.attribute_names, record, existing)
54
75
 
55
- def find_duplicate
56
- records = self.class.where(match_conditions).where(insensitive_match_conditions)
57
- if records.length > 1
58
- raise RecordNotUnique, "#{records.length} duplicate #{self.class.model_name.human.pluralize} present in database"
59
- end
76
+ existing.changed.each {|attribute| record.send("#{attribute}_will_change!") }
60
77
 
61
- return records.first
78
+ return existing.changed?
62
79
  end
63
80
 
64
- def replace(other)
65
- return unless other
66
- inherit_attributes(other)
67
- @new_record = false
68
- @has_been_replaced = true
69
- @has_not_changed = !mark_changes(other)
81
+ def self.copy_attributes(attributes, source, target)
82
+ attributes.each do |attribute|
83
+ target[attribute] = source[attribute]
84
+ end
70
85
  end
71
86
 
72
- # Inherit other's attributes for those in acts_as_replaceable_options[:inherit]
73
- def inherit_attributes(other)
74
- acts_as_replaceable_options[:inherit].each do |attrib|
75
- self[attrib] = other[attrib]
76
- end
87
+ # Searches the database for an existing copies of record
88
+ def self.find_existing(record)
89
+ existing = record.class
90
+ existing = existing.where match_conditions(record)
91
+ existing = existing.where insensitive_match_conditions(record)
77
92
  end
78
93
 
79
- def mark_changes(other)
80
- attribs = self.attributes
94
+ # Conditionally lock (lets us enable or disable locking)
95
+ def self.lock_if(condition, *lock_args, &block)
96
+ if condition
97
+ lock(*lock_args, &block)
98
+ else
99
+ yield
100
+ end
101
+ end
81
102
 
82
- # Copy attributes to other and see how it would change if we updated it
83
- # Mark all self's attributes that have changed, so even if they are
84
- # still default values, they will be saved to the database
85
- attribs.each do |key, value|
86
- other[key] = value
103
+ # A lock is used to prevent multiple threads from executing the same query simultaneously
104
+ # eg. In a multi-threaded environment, 'find_or_create' is prone to failure due to the possibility
105
+ # that the process is preempted between the 'find' and 'create' logic
106
+ def self.lock(record, timeout = 20)
107
+ lock_id = "ActsAsReplaceable/#{Digest::MD5.digest([match_conditions(record), insensitive_match_conditions(record)].inspect)}"
108
+ acquired = false
109
+
110
+ # Acquire the lock by atomically incrementing and returning the value to see if we're first
111
+ while !acquired do
112
+ unless acquired = Rails.cache.increment(lock_id) == 1
113
+ puts "lock was in use #{lock_id}"
114
+ sleep(0.250)
115
+ end
87
116
  end
88
117
 
89
- other.changed.each {|attribute| send("#{attribute}_will_change!") }
118
+ # Reserve the lock for only 10 seconds more than the timeout to ensure a lock is always eventually released
119
+ Rails.cache.write(lock_id, "1", :raw => true, :expires_in => timeout + 10)
120
+ Timeout::timeout(timeout) do
121
+ yield
122
+ end
90
123
 
91
- return other.changed?
124
+ ensure # Give up the lock
125
+ Rails.cache.write(lock_id, "0", :raw => true) if acquired
92
126
  end
127
+ end
93
128
 
94
- # Search the incoming attributes for attributes that are in the replaceable conditions and use those to form an Find conditions
95
- def match_conditions
96
- output = {}
97
- acts_as_replaceable_options[:match].each do |attribute_name|
98
- output[attribute_name] = self[attribute_name]
99
- end
100
- return output
129
+ module ClassMethods
130
+ def duplicates
131
+ columns = acts_as_replaceable_options[:match] + acts_as_replaceable_options[:insensitive_match]
132
+
133
+ dup_data = self.select(columns.join(', '))
134
+ dup_data.group! acts_as_replaceable_options[:match].join(', ')
135
+ dup_data.group! acts_as_replaceable_options[:insensitive_match].collect{|m| "LOWER(#{m}) AS #{m}"}.join(', ')
136
+ dup_data.having! "count (*) > 1"
137
+
138
+ join_condition = columns.collect{|c| "#{table_name}.#{c} = dup_data.#{c}"}.join(' AND ')
139
+
140
+ return self.joins("JOIN (#{dup_data.to_sql}) AS dup_data ON #{join_condition}")
101
141
  end
142
+ end
102
143
 
103
- def insensitive_match_conditions
104
- sql = []
105
- binds = []
106
- acts_as_replaceable_options[:insensitive_match].each do |attribute_name|
107
- if value = self[attribute_name]
108
- sql << "LOWER(#{attribute_name}) = ?"
109
- binds << self[attribute_name].downcase
144
+ module InstanceMethods
145
+ # Override the create or update method so we can run callbacks, but opt not to save if we don't need to
146
+ def create_record(*args)
147
+ ActsAsReplaceable::HelperMethods.lock_if(ActsAsReplaceable.concurrency, self) do
148
+ find_and_replace
149
+ if @has_not_changed
150
+ logger.info "(acts_as_replaceable) Found unchanged #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
151
+ elsif @has_been_replaced
152
+ update_record(*args)
153
+ logger.info "(acts_as_replaceable) Updated existing #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
110
154
  else
111
- sql << "#{attribute_name} IS NULL"
155
+ super
156
+ logger.info "(acts_as_replaceable) Created #{self.class.to_s} ##{id} #{"- Name: #{name}" if respond_to?('name')}"
112
157
  end
113
158
  end
114
- return [sql.join(' AND ')] + binds
159
+
160
+ return true
161
+ end
162
+
163
+ # Replaces self with an existing copy from the database if available, raises an exception if more than one copy exists in the database
164
+ def find_and_replace
165
+ existing = ActsAsReplaceable::HelperMethods.find_existing(self)
166
+
167
+ if existing.length > 1
168
+ raise RecordNotUnique, "#{existing.length} duplicate #{self.class.model_name.human.pluralize} present in database"
169
+ end
170
+
171
+ replace_with(existing.first) if existing.first
172
+ end
173
+
174
+ def replace_with(existing)
175
+ # Inherit target's attributes for those in acts_as_replaceable_options[:inherit]
176
+ ActsAsReplaceable::HelperMethods.copy_attributes(acts_as_replaceable_options[:inherit], existing, self)
177
+
178
+ @new_record = false
179
+ @has_been_replaced = true
180
+ @has_not_changed = !ActsAsReplaceable::HelperMethods.mark_changes(self, existing)
115
181
  end
116
182
  end
183
+
184
+ class RecordNotUnique < StandardError; end
185
+ class LockingUnavailable < StandardError; end
117
186
  end
@@ -1,3 +1,18 @@
1
1
  require 'acts_as_replaceable/acts_as_replaceable'
2
2
 
3
- ActiveRecord::Base.extend ActsAsReplaceable::ActMethod
3
+ # Rails 3 compatibility
4
+ if ActiveRecord::VERSION::MAJOR < 4
5
+ ActiveRecord::Base.class_eval do
6
+ alias_method :create_record, :create
7
+
8
+ def create(*args)
9
+ create_record(*args)
10
+ end
11
+
12
+ def update_record(*args)
13
+ update(*args)
14
+ end
15
+ end
16
+ end
17
+
18
+ ActiveRecord::Base.extend ActsAsReplaceable::ActMethod
@@ -5,8 +5,42 @@ describe 'acts_as_dag' do
5
5
  [Material, Item, Person].each(&:destroy_all) # Because we're using sqlite3 and it doesn't support transactional specs (afaik)
6
6
  end
7
7
 
8
- describe "A saved record" do
8
+ describe "Class methods" do
9
+ it "should be able to return records for which duplicates exist in the database" do
10
+ insert_model(Material, :name => 'glass')
11
+ wood1 = insert_model(Material, :name => 'wood')
12
+ wood2 = insert_model(Material, :name => 'wood')
13
+ Material.duplicates.order(:id).should == [wood1, wood2]
14
+ end
15
+ end
16
+
17
+ describe "Helper Methods" do
18
+ before(:each) { @record = insert_model(Material, :name => 'glass')}
9
19
 
20
+ it "should only allow one thread to hold the lock at a time" do
21
+ mutex = Mutex.new
22
+ counter = 0
23
+ expect do
24
+ 2.times.collect do
25
+ Thread.new do
26
+ ActsAsReplaceable::HelperMethods.lock(@record) do
27
+ expected = mutex.synchronize { counter += 1 }
28
+ sleep 1 # Long enough that the other thread can try to obtain the lock while we're asleep
29
+ raise unless expected == counter
30
+ end
31
+ end
32
+ end.each(&:join)
33
+ end.not_to raise_exception
34
+ end
35
+
36
+ it "should time out execution of a lock block after a certain amount of time" do
37
+ expect do
38
+ ActsAsReplaceable::HelperMethods.lock(@record, 1.seconds) { sleep 3 }
39
+ end.to raise_exception(Timeout::Error)
40
+ end
41
+ end
42
+
43
+ describe "when saving a record" do
10
44
  it "should raise an exception if more than one duplicate exists in the database" do
11
45
  insert_model(Material, :name => 'wood')
12
46
  insert_model(Material, :name => 'wood')
@@ -18,7 +52,7 @@ describe 'acts_as_dag' do
18
52
  insert_model(Item, :identification_number => '1234', :holding_institution_id => 1)
19
53
  lambda {Item.create! :identification_number => '1234', :holding_institution_id => 1}.should raise_exception
20
54
  end
21
-
55
+
22
56
  it "should replace itself with an existing record by matching a single column" do
23
57
  Material.create! :name => 'wood'
24
58
  Material.create! :name => 'wood'
@@ -45,7 +79,7 @@ describe 'acts_as_dag' do
45
79
  c.count.should == 1
46
80
  c.first.name.should == 'Dip Stick'
47
81
  end
48
-
82
+
49
83
  it "should correctly replace an existing record when a match value is nil" do
50
84
  a = Item.create! :name => 'Stick', :identification_number => '1234', :holding_institution_id => 1
51
85
  b = Item.create! :name => 'Dip Stick', :identification_number => '1234', :holding_institution_id => 1
@@ -61,7 +95,7 @@ describe 'acts_as_dag' do
61
95
  Person.create! :first_name => 'Alanson', :last_name => 'Skinner'
62
96
  Person.where(:first_name => 'Alanson', :last_name => 'Skinner').count.should == 1
63
97
  end
64
-
98
+
65
99
  it "should not replace an existing record with fields that were used to match" do
66
100
  Person.create! :first_name => 'joHn', :last_name => 'doE'
67
101
  Person.create! :first_name => 'John', :last_name => 'Doe'
@@ -92,5 +126,36 @@ describe 'acts_as_dag' do
92
126
  b = Material.create! :name => 'wood'
93
127
  b.persisted?.should be_true
94
128
  end
129
+
130
+ # CONCURRENCY
131
+
132
+ it "should raise an exception if concurrency is enabled but Rails.cache doesn't support the :increment method" do
133
+ ActsAsReplaceable.concurrency = true
134
+ old_cache = Rails.cache
135
+ Rails.cache = Object.new
136
+
137
+ begin
138
+ expect do
139
+ class TestClass < ActiveRecord::Base
140
+ self.table_name = Material.table_name
141
+ acts_as_replaceable
142
+ end
143
+ end.to raise_exception(ActsAsReplaceable::LockingUnavailable)
144
+ ensure
145
+ Rails.cache = old_cache
146
+ end
147
+ end
148
+
149
+ it "should use locking if concurrency is enabled" do
150
+ ActsAsReplaceable.concurrency = true
151
+ ActsAsReplaceable::HelperMethods.should_receive(:lock).once
152
+ Material.create! :name => 'wood'
153
+ end
154
+
155
+ it "should not use locking if concurrency is disabled" do
156
+ ActsAsReplaceable.concurrency = false
157
+ ActsAsReplaceable::HelperMethods.should_not_receive(:lock)
158
+ Material.create! :name => 'wood'
159
+ end
95
160
  end
96
- end
161
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  $LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
2
+
2
3
  require 'active_record'
3
4
  require 'logger'
4
5
  require 'acts_as_replaceable'
@@ -15,7 +16,7 @@ ActiveRecord::Schema.define(:version => 0) do
15
16
  t.string :name
16
17
  t.string :fingerprint
17
18
  end
18
-
19
+
19
20
  create_table :people, :force => true do |t|
20
21
  t.string :first_name
21
22
  t.string :last_name
@@ -54,4 +55,37 @@ end
54
55
 
55
56
  def insert_model(klass, attributes)
56
57
  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
58
+ return klass.order(:id).last
59
+ end
60
+
61
+ class Rails
62
+ def self.cache
63
+ @cache ||= Cache.new
64
+ end
65
+
66
+ def self.cache=(cache)
67
+ @cache = cache
68
+ end
69
+
70
+ class Cache
71
+ def initialize
72
+ @lock = Mutex.new
73
+ @store = {}
74
+ end
75
+
76
+ def write(key, value, *args)
77
+ @lock.synchronize do
78
+ @store[key] = value
79
+ end
80
+ end
81
+
82
+ def increment(key, *args)
83
+ @lock.synchronize do
84
+ @store[key] = @store[key].to_i + 1
85
+ end
86
+ end
87
+ end
88
+
89
+ # init cache so it's there before multiple threads race to initialize it and end up with two different caches
90
+ self.cache
91
+ end
metadata CHANGED
@@ -1,8 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acts_as_replaceable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
5
- prerelease:
4
+ version: 1.2.0
6
5
  platform: ruby
7
6
  authors:
8
7
  - Nicholas Jakobsen
@@ -10,24 +9,28 @@ authors:
10
9
  autorequire:
11
10
  bindir: bin
12
11
  cert_chain: []
13
- date: 2013-09-03 00:00:00.000000000 Z
12
+ date: 2013-10-02 00:00:00.000000000 Z
14
13
  dependencies:
15
14
  - !ruby/object:Gem::Dependency
16
15
  name: rails
17
16
  requirement: !ruby/object:Gem::Requirement
18
- none: false
19
17
  requirements:
20
- - - ~>
18
+ - - '>='
21
19
  - !ruby/object:Gem::Version
22
- version: '4.0'
20
+ version: '3.2'
21
+ - - <
22
+ - !ruby/object:Gem::Version
23
+ version: 4.1.0
23
24
  type: :runtime
24
25
  prerelease: false
25
26
  version_requirements: !ruby/object:Gem::Requirement
26
- none: false
27
27
  requirements:
28
- - - ~>
28
+ - - '>='
29
+ - !ruby/object:Gem::Version
30
+ version: '3.2'
31
+ - - <
29
32
  - !ruby/object:Gem::Version
30
- version: '4.0'
33
+ version: 4.1.0
31
34
  description:
32
35
  email: technical@rrnpilot.org
33
36
  executables: []
@@ -41,27 +44,26 @@ files:
41
44
  - README.rdoc
42
45
  homepage: http://github.com/rrn/acts_as_replaceable
43
46
  licenses: []
47
+ metadata: {}
44
48
  post_install_message:
45
49
  rdoc_options: []
46
50
  require_paths:
47
51
  - lib
48
52
  required_ruby_version: !ruby/object:Gem::Requirement
49
- none: false
50
53
  requirements:
51
54
  - - '>='
52
55
  - !ruby/object:Gem::Version
53
56
  version: '0'
54
57
  required_rubygems_version: !ruby/object:Gem::Requirement
55
- none: false
56
58
  requirements:
57
59
  - - '>='
58
60
  - !ruby/object:Gem::Version
59
61
  version: '0'
60
62
  requirements: []
61
63
  rubyforge_project:
62
- rubygems_version: 1.8.25
64
+ rubygems_version: 2.1.10
63
65
  signing_key:
64
- specification_version: 3
66
+ specification_version: 4
65
67
  summary: Overloads the create_or_update_without_callbacks method to allow duplicate
66
68
  records to be replaced without needing to always use find_or_create_by.
67
69
  test_files: []