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 +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: []
|