activerecord-update 0.0.1

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.
@@ -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: []