acts_as_replaceable 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: []