activerecord-update 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ea8267a673e2b98e469272b396c95715d72293a0
4
+ data.tar.gz: 758e3c003b4711eb333f99dc89488a9f84dbf6e0
5
+ SHA512:
6
+ metadata.gz: 6bd413d5d665399e192852db292c1624ea7d6b5e95ab86bc4956aeb5ad56a1aec83f3c1ecb6c4920f1628fdb07892ddbf39e68d46d571bb92552f01fc427bf2a
7
+ data.tar.gz: 65ffd2ccf31fab618f5cf5e316691321cbeebdaf33a785a495c1db51f3b2a75658c95a9f5d16092c3b4b141bdc4d958a036897b45461172b34cf8a48f1092184
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /profile_reports/
9
+ /spec/reports/
10
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,16 @@
1
+ Style/Documentation:
2
+ Enabled: false
3
+
4
+ Style/SignalException:
5
+ Enabled: false
6
+
7
+ Metrics/ClassLength:
8
+ Enabled: false
9
+
10
+ Style/MultilineMethodCallIndentation:
11
+ EnforcedStyle: indented
12
+
13
+ Metrics/BlockLength:
14
+ Exclude:
15
+ - activerecord-update.gemspec
16
+ - spec/**/*_spec.rb
@@ -0,0 +1 @@
1
+ activerecord-update
@@ -0,0 +1 @@
1
+ 2.1.5
@@ -0,0 +1,17 @@
1
+ sudo: false
2
+ language: ruby
3
+
4
+ rvm:
5
+ - 2.3.3
6
+ - 2.1.5
7
+
8
+ cache: bundler
9
+ before_install: gem install bundler -v 1.13.6
10
+
11
+ before_script:
12
+ - bundle exec rake db:create
13
+ - bundle exec rake db:migrate
14
+
15
+ script:
16
+ - bundle exec rubocop
17
+ - bundle exec rspec
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
@@ -0,0 +1,59 @@
1
+ # activerecord-update [![Build Status](https://travis-ci.org/jacob-carlborg/activerecord-update.svg?branch=master)](https://travis-ci.org/jacob-carlborg/activerecord-update)
2
+
3
+ activerecord-update is a library for doing batch updates using ActiveRecord.
4
+ Currently it only supports PostgreSQL.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'activerecord-update'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ $ bundle
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install activerecord-update
21
+
22
+ ## Usage
23
+
24
+ All ActiveRecord classes have two new class methods added, `update_records` and
25
+ `update_records!`. Both expect an array of ActiveRecord models. The difference
26
+ between these methods is that `update_records!` will raise an error if any
27
+ validations fail or any stale objects are identified, if optimistic locking is
28
+ enabled.
29
+
30
+ ```ruby
31
+ class Book
32
+ validates :title, presence: true
33
+ end
34
+
35
+ books = Book.find(1, 2, 3)
36
+ books.each_with_index { |book, index| book.title = "foo_#{index}" }
37
+ Book.update_records(books)
38
+
39
+ books.each { |book| book.title = nil }
40
+ Book.update_records!(books) # will raise an ActiveRecord::RecordInvalid error
41
+ ```
42
+
43
+ ## Development
44
+
45
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
46
+ run `rake spec` to run the tests. You can also run `bin/console` for an
47
+ interactive prompt that will allow you to experiment.
48
+
49
+ To install this gem onto your local machine, run `bundle exec rake install`.
50
+ To release a new version, update the version number in `version.rb`, and then
51
+ run `bundle exec rake release`, which will create a git tag for the version,
52
+ push git commits and tags, and push the `.gem` file to
53
+ [rubygems.org](https://rubygems.org).
54
+
55
+ ## Contributing
56
+
57
+ Bug reports and pull requests are welcome on GitHub at
58
+ https://github.com/jacob-carlborg/activerecord-update.
59
+
@@ -0,0 +1,33 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
7
+
8
+ require 'activerecord-update'
9
+ require ActiveRecord::Update.root.join('spec/support/database')
10
+
11
+ namespace :db do
12
+ desc 'Creates the database from spec/db/database.yml'
13
+ task :create do
14
+ name = ActiveRecord::Update.database.name
15
+ sh "psql -c 'create database #{name};' -U postgres"
16
+ end
17
+
18
+ desc 'Drops the database from spec/db/database.yml'
19
+ task :drop do
20
+ name = ActiveRecord::Update.database.name
21
+ sh "psql -c 'drop database #{name};' -U postgres"
22
+ end
23
+
24
+ desc 'Migrate the database'
25
+ task :migrate do
26
+ ActiveRecord::Update.database.migrate
27
+ end
28
+ end
29
+
30
+ desc 'Start the console'
31
+ task :c do
32
+ exec './bin/console'
33
+ end
@@ -0,0 +1,50 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'activerecord-update/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'activerecord-update'
8
+ spec.version = ActiveRecord::Update::VERSION
9
+ spec.authors = ['Jacob Carlborg']
10
+ spec.email = ['doob@me.se']
11
+
12
+ spec.summary = 'Batch updating for ActiveRecord models'
13
+ spec.description = 'Batch updating for ActiveRecord models'
14
+ spec.homepage = 'https://github.com/jacob-carlborg/activerecord-update'
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the
17
+ # 'allowed_push_host' to allow pushing to a single host or delete this section
18
+ # to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
21
+ else
22
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
23
+ 'public gem pushes.'
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+
30
+ spec.bindir = 'exe'
31
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ['lib']
33
+
34
+ spec.add_dependency 'activerecord', '4.1.9'
35
+ spec.add_dependency 'activesupport', '4.1.9'
36
+
37
+ spec.add_development_dependency 'database_cleaner', '~> 1.5'
38
+ spec.add_development_dependency 'bundler', '~> 1.13'
39
+ spec.add_development_dependency 'pg', '0.18.2'
40
+ spec.add_development_dependency 'pry-rescue', '~> 1.4'
41
+ spec.add_development_dependency 'pry-stack_explorer', '~> 0.4.9'
42
+ spec.add_development_dependency 'rake', '~> 10.0'
43
+ spec.add_development_dependency 'redcarpet', '~> 3.3'
44
+ spec.add_development_dependency 'rspec', '~> 3.5'
45
+ spec.add_development_dependency 'rubocop', '0.46.0'
46
+ spec.add_development_dependency 'ruby-prof', '~> 0.16'
47
+ spec.add_development_dependency 'simplecov', '~> 0.12'
48
+ spec.add_development_dependency 'timecop', '~> 0.8'
49
+ spec.add_development_dependency 'yard', '~> 0.9'
50
+ end
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'pry'
5
+ require 'activerecord-update'
6
+ require_relative '../spec/support/database'
7
+ require_relative '../spec/models/record'
8
+
9
+ ActiveRecord::Update.database.connect
10
+ at_exit { ActiveRecord::Update.database.disconnect }
11
+
12
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,16 @@
1
+ # rubocop:disable Style/FileName
2
+
3
+ require 'active_support/core_ext/string/strip'
4
+ require 'active_record'
5
+
6
+ require 'activerecord-update/version'
7
+ require 'activerecord-update/active_record/base'
8
+ require 'activerecord-update/active_record/update/result'
9
+
10
+ module ActiveRecord
11
+ module Update
12
+ def self.root
13
+ @root ||= Pathname.new(File.dirname(__FILE__)).join('..')
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,614 @@
1
+ module ActiveRecord
2
+ class Base
3
+ UPDATE_RECORDS_SQL_TEMPLATE = <<-SQL.strip_heredoc.strip.freeze
4
+ UPDATE %{table} SET
5
+ %{set_columns}
6
+ FROM (
7
+ VALUES
8
+ %{type_casts},
9
+ %{values}
10
+ )
11
+ AS %{alias}(%{columns})
12
+ WHERE %{table}.%{primary_key} = %{alias}.%{primary_key}
13
+ SQL
14
+
15
+ private_constant :UPDATE_RECORDS_SQL_TEMPLATE
16
+
17
+ UPDATE_RECORDS_SQL_LOCKING_CONDITION = <<-SQL.strip_heredoc.strip.freeze
18
+ AND %{table}.%{locking_column} = %{alias}.%{prev_locking_column}
19
+ SQL
20
+
21
+ private_constant :UPDATE_RECORDS_SQL_LOCKING_CONDITION
22
+
23
+ UPDATE_RECORDS_SQL_FOOTER = <<-SQL.strip_heredoc.strip.freeze
24
+ RETURNING %{table}.%{primary_key}
25
+ SQL
26
+
27
+ private_constant :UPDATE_RECORDS_SQL_FOOTER
28
+
29
+ class << self
30
+ # Updates a list of records in a single batch.
31
+ #
32
+ # This is more efficient than calling `ActiveRecord::Base#save` multiple
33
+ # times.
34
+ #
35
+ # * Only records that have changed will be updated
36
+ # * All new records will be ignored
37
+ # * Validations will be performed for all the records
38
+ # * Only the records for which the validations pass will be updated
39
+ #
40
+ # * The union of the changed attributes for all the records will be
41
+ # updated
42
+ #
43
+ # * The `updated_at` attribute will be updated for all the records that
44
+ # where updated
45
+ #
46
+ # * All the given records should be of the same type and the same type
47
+ # as the class this method is called on
48
+ #
49
+ # * If the model is using optimistic locking, that is honored
50
+ #
51
+ # @example
52
+ # Model.update_records(array_of_models)
53
+ #
54
+ # @param records [<ActiveRecord::Base>] the records to be updated
55
+ #
56
+ # @return [ActiveRecord::Update::Result] the ID's of the records that
57
+ # were updated and the records that failed to validate
58
+ #
59
+ # @see ActiveRecord::Update::Result
60
+ # @see .update_records!
61
+ def update_records(records)
62
+ _update_records(
63
+ records,
64
+ raise_on_validation_failure: false,
65
+ raise_on_stale_objects: false
66
+ )
67
+ end
68
+
69
+ # (see .update_records)
70
+ #
71
+ # The difference compared to {.update_records} is that this method will
72
+ # raise on validation failures. It will pick the first failing record and
73
+ # raise the error based that record's failing validations.
74
+ #
75
+ # If an `ActiveRecord::RecordInvalid` error is raised none of the records
76
+ # will be updated, including the valid records.
77
+ #
78
+ # If an `ActiveRecord::StaleObjectError` error is raised, some of the
79
+ # records might have been updated and is reflected in the
80
+ # {ActiveRecord::Update::Result#ids} and
81
+ # {ActiveRecord::Update::Result#stale_objects} attributes on the return
82
+ # value.
83
+ #
84
+ # @raise [ActiveRecord::RecordInvalid] if any records failed to validate
85
+ #
86
+ # @raise [ActiveRecord::StaleObjectError] if optimistic locking is enabled
87
+ # and there were stale objects
88
+ #
89
+ # @see .update_records
90
+ def update_records!(records)
91
+ _update_records(
92
+ records,
93
+ raise_on_validation_failure: true,
94
+ raise_on_stale_objects: true
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ # rubocop:disable Metrics/MethodLength
101
+
102
+ # (see .update_records)
103
+ #
104
+ # @param raise_on_validation_failure [Boolean] if `true`, an error will be
105
+ # raised for any validation failures
106
+ #
107
+ # @param raise_on_stale_objects [Boolean] if `true`, an error will be
108
+ # raised if optimistic locking is used and there are stale objects
109
+ #
110
+ # @see .update_records
111
+ def _update_records(
112
+ records,
113
+ raise_on_validation_failure:,
114
+ raise_on_stale_objects:
115
+ )
116
+
117
+ changed = changed_records(records)
118
+ valid, failed = validate_records(changed, raise_on_validation_failure)
119
+ return build_result(valid, failed, []) if valid.empty?
120
+
121
+ timestamp = current_time
122
+ query = sql_for_update_records(valid, timestamp)
123
+ ids = perform_update_records_query(query, primary_key)
124
+ result = build_result(valid, failed, ids)
125
+ validate_result(result, raise_on_stale_objects)
126
+
127
+ update_timestamp(valid, timestamp)
128
+ mark_changes_applied(valid)
129
+ result
130
+ end
131
+ # rubocop:enable Metrics/MethodLength
132
+
133
+ # Returns the given records that are not new records and have changed.
134
+ #
135
+ # @param records [<ActiveRecord::Base>] the records to filter
136
+ # @return that records that are not new records and have changed
137
+ def changed_records(records)
138
+ records.reject(&:new_record?).select(&:changed?)
139
+ end
140
+
141
+ # Validates the given records.
142
+ #
143
+ # @param records [<ActiveModel::Validations>] the records to validate
144
+ # @param raise_on_validation_failure [Boolean] if `true`, an error will be
145
+ # raised for any validation failures
146
+ #
147
+ # @return [(<ActiveModel::Validations>, <ActiveModel::Validations>)]
148
+ # a tuple where the first element is an array of records that are valid.
149
+ # The second element is an array of the invalid records
150
+ #
151
+ # @raise [ActiveRecord::RecordInvalid] if `raise_on_validation_failure`
152
+ # is `true` and there are validation failures
153
+ def validate_records(records, raise_on_validation_failure)
154
+ valid, invalid = records.partition(&:valid?)
155
+
156
+ if raise_on_validation_failure && invalid.any?
157
+ raise RecordInvalid, invalid.first
158
+ end
159
+
160
+ [valid, invalid]
161
+ end
162
+
163
+ # Builds the SQL query used by the {#update_records} method.
164
+ #
165
+ # @example
166
+ # class Model
167
+ # include ActiveModel::Model
168
+ # include ActiveModel::Dirty
169
+ #
170
+ # attr_accessor :id, :foo, :bar
171
+ # define_attribute_methods :id, :foo, :bar
172
+ #
173
+ # def slice(*keys)
174
+ # attributes = { id: id, foo: foo, bar: bar }
175
+ # hash = ActiveSupport::HashWithIndifferentAccess.new(attributes)
176
+ # hash.slice(*keys)
177
+ # end
178
+ #
179
+ # def id=(value)
180
+ # id_will_change! unless value == @id
181
+ # @id = value
182
+ # end
183
+ #
184
+ # def foo=(value)
185
+ # foo_will_change! unless value == @foo
186
+ # @foo = value
187
+ # end
188
+ #
189
+ # def bar=(value)
190
+ # bar_will_change! unless value == @bar
191
+ # @bar = value
192
+ # end
193
+ # end
194
+ #
195
+ # record1 = Model.new(id: 1, foo: 4, bar: 5)
196
+ # record2 = Model.new(id: 2, foo: 2, bar: 3)
197
+ # records = [record1, record2]
198
+ #
199
+ # ActiveRecord::Base.send(:sql_for_update_records, records)
200
+ # # =>
201
+ # # UPDATE "foos" SET
202
+ # # "id" = "foos_2"."id", "foo" = "foos_2"."foo", "bar" = "foos_2"."bar
203
+ # # FROM (
204
+ # # VALUES (1, 4, 5), (2, 2, 3)
205
+ # # )
206
+ # # AS foos_2("id", "foo", "bar")
207
+ # # WHERE "foos"."id" = foos_2."id"
208
+ # # RETURNING "foos"."id";
209
+ #
210
+ # @param records [<ActiveRecord::Base>] the records that have changed
211
+ # @param timestamp [Time] the timestamp used for the `updated_at` column
212
+ #
213
+ # @return the SQL query for the #{update_records} method
214
+ #
215
+ # @see #update_records
216
+ # rubocop:disable Metrics/MethodLength
217
+ # rubocop:disable Metrics/AbcSize
218
+ def sql_for_update_records(records, timestamp)
219
+ attributes = changed_attributes(records)
220
+ quoted_changed_attributes = changed_attributes_for_sql(
221
+ attributes, quoted_table_alias
222
+ )
223
+
224
+ attributes = all_attributes(attributes)
225
+ casts = type_casts(attributes)
226
+ values = changed_values(records, attributes, timestamp)
227
+ quoted_values = values_for_sql(values)
228
+ quoted_column_names = column_names_for_sql(attributes)
229
+ template = build_sql_template
230
+
231
+ options = build_format_options(
232
+ table: quoted_table_name,
233
+ set_columns: quoted_changed_attributes,
234
+ type_casts: casts,
235
+ values: quoted_values,
236
+ alias: quoted_table_alias,
237
+ columns: quoted_column_names,
238
+ primary_key: quoted_primary_key
239
+ )
240
+
241
+ format(template, options)
242
+ end
243
+ # rubocop:enable Metrics/AbcSize
244
+ # rubocop:enable Metrics/MethodLength
245
+
246
+ # @return [Time] the current time in the ActiveRecord timezone.
247
+ def current_time
248
+ default_timezone == :utc ? Time.now.getutc : Time.now
249
+ end
250
+
251
+ # @return [String] the table alias quoted.
252
+ def quoted_table_alias
253
+ @quoted_table_alias ||=
254
+ connection.quote_table_name(arel_table.alias.name)
255
+ end
256
+
257
+ # Quotes/escapes the given column value to prevent SQL injection attacks.
258
+ #
259
+ # This is an PostgreSQL specific method which properly handles quoting of
260
+ # `true` and `false`. The built-in ActiveRecord quote method will convert
261
+ # these values to `"'t'"` and `"'f'"`, which will not work for
262
+ # {.update_records}. This method will convert these values to `"TRUE"` and
263
+ # `"FALSE"` instead. All other values are delegated to
264
+ # `ActiveRecord::ConnectionAdapters::Quoting#quote`.
265
+ #
266
+ # @param value [Object] the value to escape
267
+ # @return [String] the quoted value
268
+ def quote(value)
269
+ case value
270
+ when true, false
271
+ value ? 'TRUE' : 'FALSE'
272
+ else
273
+ connection.quote(value)
274
+ end
275
+ end
276
+
277
+ # Returns the changed attribute names of the given records.
278
+ #
279
+ # When locking is enabled the locking column will be inserted as well in
280
+ # the return value.
281
+ #
282
+ # @param records [<ActiveModel::Dirty>] the records to return the changed
283
+ # attributes for
284
+ #
285
+ # @return [Set<String>] a list of the names of the attributes that have
286
+ # changed
287
+ def changed_attributes(records)
288
+ changed = records.flat_map(&:changed)
289
+ return changed if changed.empty?
290
+
291
+ attrs = changed.dup << 'updated_at'
292
+ attrs << locking_column if locking_enabled?
293
+ attrs.tap(&:uniq!)
294
+ end
295
+
296
+ # Returns the given changed attributes formatted for SQL.
297
+ #
298
+ # @param changed_attributes [Set<String>] the attributes that have changed
299
+ # @param table_alias [String] an alias for the table name
300
+ #
301
+ # @return [String] the changed attributes formatted for SQL
302
+ # @raise [ArgumentError] if the given list is `nil` or empty
303
+ def changed_attributes_for_sql(changed_attributes, table_alias)
304
+ if changed_attributes.blank?
305
+ raise ArgumentError, 'No changed attributes given'
306
+ end
307
+
308
+ changed_attributes
309
+ .map { |e| connection.quote_column_name(e) }
310
+ .map { |e| "#{e} = #{table_alias}.#{e}" }.join(', ')
311
+ end
312
+
313
+ # @return [<String>] all attributes, that is, the given attributes plus
314
+ # some extra, like the primary key.
315
+ def all_attributes(attributes)
316
+ [primary_key] + attributes
317
+ end
318
+
319
+ # Returns a row used for typecasting the values that will be updated.
320
+ #
321
+ # This is needed because many types don't have a specific literal syntax
322
+ # and are instead using the string literal syntax. This will cause type
323
+ # mismatches because there's not context, which is otherwise present for
324
+ # regular inserts, for the values to infer the types from.
325
+ #
326
+ # When locking is enabled a virtual prev locking column is inserted,
327
+ # called `'prev_' + locking_column`, at the second position, after the
328
+ # primary key. This column is used in the where condition to implement
329
+ # the optimistic locking feature.
330
+ #
331
+ # @example
332
+ # ActiveRecord::Base.send(:type_casts, %w(id foo bar))
333
+ # # => (NULL::integer, NULL::character varying(255), NULL::boolean)
334
+ #
335
+ # @param column_names [Set<String>] the name of the columns
336
+ #
337
+ # @raise [ArgumentError]
338
+ # * If the given list of column names is `nil` or empty
339
+ def type_casts(column_names)
340
+ raise ArgumentError, 'No column names given' if column_names.blank?
341
+
342
+ type_casts = column_names.dup
343
+
344
+ # This is the virtual prev locking column. We're using the same name as
345
+ # the locking column since the column is virtual it does not exist in
346
+ # `columns_hash` hash. That works fine since we're only interested in
347
+ # the SQL type, which will always be the same for the locking and prev
348
+ # locking columns.
349
+ type_casts.insert(1, locking_column) if locking_enabled?
350
+ type_casts.map! { |n| 'NULL::' + columns_hash[n].sql_type }
351
+
352
+ '(' + type_casts.join(', ') + ')'
353
+ end
354
+
355
+ # rubocop:disable Metrics/MethodLength
356
+
357
+ # Returns the values of the given records that have changed.
358
+ #
359
+ # @example
360
+ # class Model
361
+ # include ActiveModel::Model
362
+ #
363
+ # attr_accessor :id, :foo, :bar
364
+ #
365
+ # def slice(*keys)
366
+ # attributes = { id: id, foo: foo, bar: bar }
367
+ # hash = ActiveSupport::HashWithIndifferentAccess.new(attributes)
368
+ # hash.slice(*keys)
369
+ # end
370
+ # end
371
+ #
372
+ # record1 = Model.new(id: 1, foo: 3)
373
+ # record2 = Model.new(id: 2, bar: 4)
374
+ # records = [record1, record2]
375
+ #
376
+ # changed_attributes = Set.new(%w(id foo bar))
377
+ # ActiveRecord::Base.send(
378
+ # :changed_values, records, changed_attributes, Time.at(0)
379
+ # )
380
+ # # => [
381
+ # # [1, 3, nil, 1970-01-01 01:00:00 +0100],
382
+ # # [2, nil, 4, 1970-01-01 01:00:00 +0100]
383
+ # # ]
384
+ #
385
+ # @param records [<ActiveRecord::Base>] the records that have changed
386
+ #
387
+ # @param changed_attributes [Set<String>] the attributes that have
388
+ # changed, including the primary key, and the locking column, if locking
389
+ # is enabled
390
+ #
391
+ # @param updated_at [Time] the value of the updated_at column
392
+ #
393
+ # @return [<<Object>>] the changed values
394
+ #
395
+ # @raise [ArgumentError]
396
+ # * if the given list of records or changed attributes is `nil` or empty
397
+ def changed_values(records, changed_attributes, updated_at)
398
+ raise ArgumentError, 'No changed records given' if records.blank?
399
+
400
+ if changed_attributes.blank?
401
+ raise ArgumentError, 'No changed attributes given'
402
+ end
403
+
404
+ extract_changed_values = lambda do |record|
405
+ previous_lock_value = increment_lock(record)
406
+
407
+ # We're using `slice` instead of `changed_attributes` because we need
408
+ # to include all the changed attributes from all the changed records
409
+ # and not just the changed attributes for a given record.
410
+ values = record
411
+ .slice(*changed_attributes)
412
+ .merge('updated_at' => updated_at)
413
+ .values
414
+
415
+ locking_enabled? ? values.insert(1, previous_lock_value) : values
416
+ end
417
+
418
+ records.map(&extract_changed_values)
419
+ end
420
+ # rubocop:enable Metrics/MethodLength
421
+
422
+ # Increments the lock column of the given record if locking is enabled.
423
+ #
424
+ # @param [ActiveRecord::Base] the record to update the lock column for
425
+ # @return [void]
426
+ def increment_lock(record)
427
+ return unless locking_enabled?
428
+
429
+ lock_col = locking_column
430
+ previous_lock_value = record.send(lock_col).to_i
431
+ record.send(lock_col + '=', previous_lock_value + 1)
432
+ previous_lock_value
433
+ end
434
+
435
+ # Returns the values of the given records that have changed, formatted for
436
+ # SQL.
437
+ #
438
+ # @example
439
+ # changed_values = [
440
+ # [1, 3, 4, 1970-01-01 01:00:00 +0100],
441
+ # [2, 5, 6, 1970-01-01 01:00:00 +0100]
442
+ # ]
443
+ # ActiveRecord::Base.send(:values_for_sql, records)
444
+ # # => "(1, 3, NULL), (2, NULL, 4)"
445
+ #
446
+ # @param changed_values [<<Object>>] the values that have changed
447
+ # @return [String] the values formatted for SQL
448
+ #
449
+ # @raise [ArgumentError] if the given list of changed values is `nil` or
450
+ # empty
451
+ def values_for_sql(changed_values)
452
+ raise ArgumentError, 'No changed values given' if changed_values.blank?
453
+
454
+ changed_values
455
+ .map { |e| '(' + e.map { |b| quote(b) }.join(', ') + ')' }
456
+ .join(', ')
457
+ end
458
+
459
+ # Returns the name of the previous locking column.
460
+ #
461
+ # This column is used when locking is enabled. It's used in the where
462
+ # condition when looking for matching rows to update.
463
+ #
464
+ # @return [String] the name of the previous locking column
465
+ def prev_locking_column
466
+ @prev_locking_column ||= 'prev_' + locking_column
467
+ end
468
+
469
+ # Returns the given column names formatted for SQL.
470
+ #
471
+ # @example
472
+ # ActiveRecord::Base.send(:column_names_for_sql, %w(id foo bar))
473
+ # # => '"id", "foo", "bar"'
474
+ #
475
+ # @param column_names [<String>] the name of the columns
476
+ #
477
+ # @return [String] the column names formatted for SQL
478
+ #
479
+ # @raise [ArgumentError]
480
+ # * If the given list of column names is `nil` or empty
481
+ def column_names_for_sql(column_names)
482
+ raise ArgumentError, 'No column names given' if column_names.blank?
483
+
484
+ names = column_names.dup
485
+ names.insert(1, prev_locking_column) if locking_enabled?
486
+ names.map! { |e| connection.quote_column_name(e) }.join(', ')
487
+ end
488
+
489
+ # Builds the SQL template for the query.
490
+ #
491
+ # This method will choose the correct template depending on if locking is
492
+ # enabled or not.
493
+ #
494
+ # @return [String] the SQL template
495
+ def build_sql_template
496
+ template =
497
+ if locking_enabled?
498
+ UPDATE_RECORDS_SQL_TEMPLATE + "\n" +
499
+ UPDATE_RECORDS_SQL_LOCKING_CONDITION
500
+ else
501
+ UPDATE_RECORDS_SQL_TEMPLATE
502
+ end
503
+
504
+ template + "\n" + UPDATE_RECORDS_SQL_FOOTER
505
+ end
506
+
507
+ # Build the option hash used for the call to `format` in
508
+ # {Base#sql_for_update_records}.
509
+ #
510
+ # This method will add the format options to the given hash of options as
511
+ # necessary if locking is enabled.
512
+ #
513
+ # @param options [{ Symbol => String }] the format options
514
+ #
515
+ # @return [{ Symbol => String }] the format options
516
+ def build_format_options(options)
517
+ if locking_enabled?
518
+ prev_col_name = connection.quote_column_name(prev_locking_column)
519
+ col_name = connection.quote_column_name(locking_column)
520
+
521
+ options.merge(
522
+ locking_column: col_name,
523
+ prev_locking_column: prev_col_name
524
+ )
525
+ else
526
+ options
527
+ end
528
+ end
529
+
530
+ # Performs the given query and returns the result of the query.
531
+ #
532
+ # @param query [String] the query to perform
533
+ # @param primary_key [String] the primary key
534
+ #
535
+ # @return the result of the query, the primary keys of the records what
536
+ # were updated
537
+ def perform_update_records_query(query, primary_key)
538
+ primary_key_column = columns_hash[primary_key]
539
+ values = connection.execute(query).values.flatten
540
+ values.map! { |e| primary_key_column.type_cast(e) }
541
+ end
542
+
543
+ # Raises an exception if the given result contain any stale objects.
544
+ #
545
+ # @param result [ActiveRecord::Update::Result] the result to check if it
546
+ # contains stale objects
547
+ #
548
+ # @return [void]
549
+ def validate_result(result, raise_on_stale_objects)
550
+ return unless result.stale_objects?
551
+ record = result.stale_objects.first
552
+ return unless raise_on_stale_objects
553
+ raise ActiveRecord::StaleObjectError.new(record, 'update')
554
+ end
555
+
556
+ # Builds the result, returned from {#update_records}, based on the given
557
+ # arguments.
558
+ #
559
+ # @param valid [<ActiveRecord::Base>] the list of records which was
560
+ # successfully validate
561
+ #
562
+ # @param failed [<ActiveRecord::Base>] the list of records which failed to
563
+ # validate
564
+ #
565
+ # @param primary_keys [<Integer>] the list of primary keys that were
566
+ # update
567
+ def build_result(valid, failed, primary_keys)
568
+ stale_objects = extract_stale_objects(valid, primary_keys)
569
+ ActiveRecord::Update::Result.new(primary_keys, failed, stale_objects)
570
+ end
571
+
572
+ # Extracts the stale objects from the given list of records.
573
+ #
574
+ # Will always return an empty list if locking is not enabled for this
575
+ # class.
576
+ #
577
+ # @param records [<ActiveRecord::Base>] the list of records to extract
578
+ # the stale objects from
579
+ #
580
+ # @param primary_keys [<Integer>] the list of primary keys that were
581
+ # updated
582
+ #
583
+ # @return [<ActiveRecord::Base>] the stale objects
584
+ def extract_stale_objects(records, primary_keys)
585
+ return [] unless locking_enabled?
586
+ primary_key_set = primary_keys.to_set
587
+ records.reject { |e| primary_key_set.include?(e.send(primary_key)) }
588
+ end
589
+
590
+ # Updates the `updated_at` attribute for the given records.
591
+ #
592
+ # This will only updated the actual Ruby objects, the database should
593
+ # already have been updated by this point.
594
+ #
595
+ # @param records [<ActiveRecord::Base>] the records that should have their
596
+ # timestamp updated
597
+ #
598
+ # @return [void]
599
+ def update_timestamp(records, timestamp)
600
+ records.each { |e| e.updated_at = timestamp }
601
+ end
602
+
603
+ # Mark changes applied for the given records.
604
+ #
605
+ # @param records [<ActiveModel::Dirty>] the records to mark changes
606
+ # applied for
607
+ #
608
+ # @return [void]
609
+ def mark_changes_applied(records)
610
+ records.each { |e| e.send(:changes_applied) }
611
+ end
612
+ end
613
+ end
614
+ end
@@ -0,0 +1,59 @@
1
+ module ActiveRecord
2
+ module Update
3
+ # This class represents the result return by the {Base#update_records}
4
+ # method. It contains the ID's of the records that were updated and the
5
+ # records that failed to validate and
6
+ #
7
+ # @see Base#update_records
8
+ class Result
9
+ # @return [<Integer>] the ID's of the records that were updated.
10
+ attr_reader :ids
11
+
12
+ # @return [<ActiveRecord::Base>] the records that failed to validate.
13
+ attr_reader :failed_records
14
+
15
+ # The records that failed to update due to being stale.
16
+ #
17
+ # Can only contain objects if optimistic locking is used.
18
+ #
19
+ # @return [<ActiveRecord::Base>] the stale objects
20
+ attr_reader :stale_objects
21
+
22
+ # Initialize the receiver.
23
+ #
24
+ # @param ids [<Integer>] the ID's of the records that were updated.
25
+ #
26
+ # @param failed_records [<ActiveRecord::Base>] the records that failed to
27
+ # validate
28
+ #
29
+ # @param stale_objects [<ActiveRecord::Base>] the records that failed to
30
+ # update to due being stale
31
+ def initialize(ids, failed_records, stale_objects)
32
+ @ids = ids
33
+ @failed_records = failed_records
34
+ @stale_objects = stale_objects
35
+ end
36
+
37
+ # @return [Boolean] `true` if there were no failed records or stale
38
+ # objects.
39
+ def success?
40
+ !failed_records? && !stale_objects?
41
+ end
42
+
43
+ # @return [Boolean] `true` if there were records that failed to validate.
44
+ def failed_records?
45
+ failed_records.any?
46
+ end
47
+
48
+ # @return [Boolean] `true` if there were any updated records.
49
+ def updates?
50
+ ids.any?
51
+ end
52
+
53
+ # @return [Boolean] `true` if there were any stale objects.
54
+ def stale_objects?
55
+ stale_objects.any?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveRecord
2
+ module Update
3
+ VERSION = '0.0.1'.freeze
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,270 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-update
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Jacob Carlborg
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-01-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 4.1.9
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 4.1.9
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 4.1.9
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 4.1.9
41
+ - !ruby/object:Gem::Dependency
42
+ name: database_cleaner
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.13'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.13'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pg
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 0.18.2
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 0.18.2
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry-rescue
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.4'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.4'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry-stack_explorer
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.4.9
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.4.9
111
+ - !ruby/object:Gem::Dependency
112
+ name: rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '10.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '10.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: redcarpet
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.3'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.3'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rspec
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '3.5'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '3.5'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - '='
158
+ - !ruby/object:Gem::Version
159
+ version: 0.46.0
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - '='
165
+ - !ruby/object:Gem::Version
166
+ version: 0.46.0
167
+ - !ruby/object:Gem::Dependency
168
+ name: ruby-prof
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: '0.16'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '0.16'
181
+ - !ruby/object:Gem::Dependency
182
+ name: simplecov
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '0.12'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '0.12'
195
+ - !ruby/object:Gem::Dependency
196
+ name: timecop
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - "~>"
200
+ - !ruby/object:Gem::Version
201
+ version: '0.8'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - "~>"
207
+ - !ruby/object:Gem::Version
208
+ version: '0.8'
209
+ - !ruby/object:Gem::Dependency
210
+ name: yard
211
+ requirement: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - "~>"
214
+ - !ruby/object:Gem::Version
215
+ version: '0.9'
216
+ type: :development
217
+ prerelease: false
218
+ version_requirements: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - "~>"
221
+ - !ruby/object:Gem::Version
222
+ version: '0.9'
223
+ description: Batch updating for ActiveRecord models
224
+ email:
225
+ - doob@me.se
226
+ executables: []
227
+ extensions: []
228
+ extra_rdoc_files: []
229
+ files:
230
+ - ".gitignore"
231
+ - ".rspec"
232
+ - ".rubocop.yml"
233
+ - ".ruby-gemset"
234
+ - ".ruby-version"
235
+ - ".travis.yml"
236
+ - Gemfile
237
+ - README.md
238
+ - Rakefile
239
+ - activerecord-update.gemspec
240
+ - bin/console
241
+ - bin/setup
242
+ - lib/activerecord-update.rb
243
+ - lib/activerecord-update/active_record/base.rb
244
+ - lib/activerecord-update/active_record/update/result.rb
245
+ - lib/activerecord-update/version.rb
246
+ homepage: https://github.com/jacob-carlborg/activerecord-update
247
+ licenses: []
248
+ metadata:
249
+ allowed_push_host: https://rubygems.org
250
+ post_install_message:
251
+ rdoc_options: []
252
+ require_paths:
253
+ - lib
254
+ required_ruby_version: !ruby/object:Gem::Requirement
255
+ requirements:
256
+ - - ">="
257
+ - !ruby/object:Gem::Version
258
+ version: '0'
259
+ required_rubygems_version: !ruby/object:Gem::Requirement
260
+ requirements:
261
+ - - ">="
262
+ - !ruby/object:Gem::Version
263
+ version: '0'
264
+ requirements: []
265
+ rubyforge_project:
266
+ rubygems_version: 2.2.2
267
+ signing_key:
268
+ specification_version: 4
269
+ summary: Batch updating for ActiveRecord models
270
+ test_files: []