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 +7 -0
- data/lib/acts_as_replaceable/acts_as_replaceable.rb +133 -64
- data/lib/acts_as_replaceable.rb +16 -1
- data/spec/acts_as_replaceable_spec.rb +70 -5
- data/spec/spec_helper.rb +36 -2
- metadata +15 -13
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
|
-
|
2
|
-
|
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
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
50
|
-
|
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
|
-
|
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
|
-
|
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
|
78
|
+
return existing.changed?
|
62
79
|
end
|
63
80
|
|
64
|
-
def
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
-
#
|
73
|
-
def
|
74
|
-
|
75
|
-
|
76
|
-
|
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
|
-
|
80
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
95
|
-
def
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
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
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/acts_as_replaceable.rb
CHANGED
@@ -1,3 +1,18 @@
|
|
1
1
|
require 'acts_as_replaceable/acts_as_replaceable'
|
2
2
|
|
3
|
-
|
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 "
|
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
|
-
|
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.
|
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-
|
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: '
|
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:
|
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.
|
64
|
+
rubygems_version: 2.1.10
|
63
65
|
signing_key:
|
64
|
-
specification_version:
|
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: []
|