activerecord-updateinbulk 0.1.1 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d50d84d5dbf5f42cee6fc93e3e4718b2e1b7993baac8d4bde92b7634d9e5675
4
- data.tar.gz: bc16526ad3800298a96bdb5cc3c9365d8d600ac083a9273e0f45ef8655d96fad
3
+ metadata.gz: 25ba0ab087a02a9c275429d50daa9b31bc2bc93fe4ae40c726402f4fd2f21ec2
4
+ data.tar.gz: d32540835f40aaaf11aa0180d9f49763c775ff7b55287dec5cd9db568b20f45f
5
5
  SHA512:
6
- metadata.gz: 7f1cc791591f474b8f93c124a74f27083bc6d134292d3c3a3a076370aff29e067874ffbc684e55808d639b890657300c0c9cfd9a23f2e12d161134f34ada7926
7
- data.tar.gz: 23a234ef95ada21deab31cdcefbd075caa4d856546f69ad6622d5b70ddd24d72c4b2633763b5b2e721baba3e5e013a4a2118e58ec39d2eaf62f09e00e6e4aa0c
6
+ metadata.gz: 2dee9dd00a22a4bebf1d692d926ce2dfafaf2730b3163ee384dda5038616fbef79812caba778280d7ed36523e5f0433dac5cbfc19667c8d038a79bf10fc6db0e
7
+ data.tar.gz: 8273e2806d3dfceca28214e461b0a6a7954d613e2fc9dfeebc4c754e1582121473a87817fdbc14498d534429163f3554f82ba46eb6b10c1a322aedbcffc558ae
data/README.md CHANGED
@@ -10,8 +10,6 @@ Similar to `update_all`, it returns the number of affected rows, and bumps updat
10
10
 
11
11
  Tested on Ruby 3.4 and Rails 8 for all builtin databases on latest versions.
12
12
 
13
- Important note: there is a flaky test on MySQL (that is not flaky on MariaDB) that I am still investigating.
14
-
15
13
  ## Usage
16
14
 
17
15
  ```ruby
@@ -61,11 +59,26 @@ Order.joins(:items).where(items: { status: :shipped }).update_in_bulk({
61
59
  })
62
60
  ```
63
61
 
62
+ ### Rails configuration
63
+
64
+ Railtie options are available at `config.active_record_update_in_bulk`:
65
+
66
+ - `values_table_alias` (`String`, optional): alias used for generated VALUES tables (default `"t"`).
67
+ - `ignore_scope_order` (`Boolean`, default `true`): when true, ORDER BY scopes are ignored by `update_in_bulk`; when false, ordered relations raise `NotImplementedError`.
68
+
69
+ Example initializer:
70
+
71
+ ```ruby
72
+ Rails.application.config.active_record_update_in_bulk.values_table_alias = "vals"
73
+ Rails.application.config.active_record_update_in_bulk.ignore_scope_order = true
74
+ ```
75
+
64
76
  ### Record timestamps
65
77
 
66
78
  By default `update_in_bulk` implicitly bumps update timestamps similar to `upsert_all`.
67
79
  - If the model has `updated_at`/`updated_on`, these are bumped *iff the row actually changed*.
68
80
  - Passing `record_timestamps: false` can disable bumping the update timestamps for the query.
81
+ - Passing `record_timestamps: :always` always sets update timestamps to `CURRENT_TIMESTAMP`.
69
82
  - The `updated_at` columns can also be manually assigned, this disables the implicit bump behaviour.
70
83
 
71
84
  ```ruby
@@ -75,6 +88,8 @@ Employee.update_in_bulk({
75
88
  }, record_timestamps: false)
76
89
  ```
77
90
 
91
+ Note: On MySQL [assignments are processed in series](https://dev.mysql.com/doc/refman/9.0/en/update.html), so there is no real guarantee that the timestamps are actually updated.
92
+
78
93
  ### Formulas (computed assignments)
79
94
 
80
95
  In all examples so far the queries simply assign predetermined values to rows matched, irrespective of their previous values.
@@ -91,21 +106,20 @@ Inventory.update_in_bulk({
91
106
  ```
92
107
 
93
108
  Built-in formulas:
94
- - `:add :subtract :min :max :concat_append :concat_prepend`
109
+ - `:add :subtract :concat_append :concat_prepend`
95
110
 
96
111
  Custom formulas are supported by providing a `Proc`. The proc takes `(lhs,rhs,model)` and must return an **Arel node**.
97
112
  Here `lhs` and `rhs` are instances of `Arel::Attribute` corresponding to the target table and values table respectively.
98
113
 
99
114
  ```ruby
100
- # Restock some products, but cap inventory at some maximum amount.
101
- # LEAST(metadata.max_stock, inventories.quantity + t.quantity)
102
- add_capped = proc |lhs, rhs| do
103
- Arel::Nodes::Least.new([Arel::Attribute.new("metadata", "max_stock"), lhs + rhs])
115
+ # Restock some products with a custom expression.
116
+ add_tax = proc do |lhs, rhs|
117
+ lhs + rhs + 1
104
118
  end
105
- Inventory.joins(:metadata).update_in_bulk({
119
+ Inventory.update_in_bulk({
106
120
  "Christmas balls" => { quantity: 300 },
107
121
  "Christmas tree" => { quantity: 10 }
108
- }, formulas: { quantity: add_capped })
122
+ }, formulas: { quantity: add_tax })
109
123
  ```
110
124
 
111
125
  ## Notes
@@ -124,7 +138,9 @@ The `UPDATE` is single-shot in any compliant database:
124
138
 
125
139
  ## Limitations
126
140
 
127
- There is no support for `ORDER BY`, `LIMIT`, `OFFSET`, `GROUP` or `HAVING` clauses in the relation.
141
+ By default `ORDER BY` scopes are ignored for `update_in_bulk` (configurable via railtie setting
142
+ `config.active_record_update_in_bulk.ignore_scope_order = true/false`).
143
+ There is no support for `LIMIT`, `OFFSET`, `GROUP` or `HAVING` clauses in the relation.
128
144
 
129
145
  The implementation does not automatically batch (nor reject) impermissibly large queries. The size of the values table is `rows * columns` when all rows assign to the same columns, or `rows * (distinct_columns + 1)` when the assign columns are not uniform (an extra bitmask indicator column is used).
130
146
 
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecord::UpdateInBulk
4
+ # Extension points mixed into +ActiveRecord::ConnectionAdapters::AbstractAdapter+
5
+ # and consumed by the builder/visitor pipeline.
6
+ #
7
+ # Concrete adapters may override any of these methods to describe their SQL
8
+ # capabilities and VALUES table semantics.
4
9
  module AbstractAdapter
5
10
  # Whether the database supports the SQL VALUES table constructor.
11
+ # When false, +Relation#update_in_bulk+ raises ArgumentError.
6
12
  def supports_values_tables?
7
13
  true
8
14
  end
@@ -36,6 +42,8 @@ module ActiveRecord::UpdateInBulk
36
42
  #
37
43
  # Returns the typecasted Arel node: a new node or +values_table+ itself,
38
44
  # possibly modified in place.
45
+ #
46
+ # The default implementation does no explicit type casting.
39
47
  def typecast_values_table(values_table, _columns)
40
48
  values_table
41
49
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecord::UpdateInBulk
4
- module SelectManager
5
- def alias(name)
4
+ module SelectManager # :nodoc:
5
+ def alias(name) # :nodoc:
6
6
  Arel::Nodes::TableAlias.new(self, name)
7
7
  end
8
8
  end
@@ -2,18 +2,6 @@
2
2
 
3
3
  module ActiveRecord::UpdateInBulk
4
4
  module ToSql
5
- def visit_Arel_Nodes_Least(o, collector)
6
- collector << "LEAST("
7
- inject_join(o.expressions, collector, ", ")
8
- collector << ")"
9
- end
10
-
11
- def visit_Arel_Nodes_Greatest(o, collector)
12
- collector << "GREATEST("
13
- inject_join(o.expressions, collector, ", ")
14
- collector << ")"
15
- end
16
-
17
5
  def visit_Arel_Nodes_ValuesTable(o, collector)
18
6
  row_prefix = @connection.values_table_row_prefix
19
7
 
@@ -40,7 +28,7 @@ module ActiveRecord::UpdateInBulk
40
28
  private
41
29
  def build_values_table_single_value(value, collector)
42
30
  case value
43
- when Arel::Nodes::SqlLiteral, Arel::Nodes::BindParam, ActiveModel::Attribute
31
+ when Arel::Nodes::SqlLiteral, Arel::Nodes::BoundSqlLiteral, Arel::Nodes::BindParam, ActiveModel::Attribute
44
32
  visit(value, collector)
45
33
  else
46
34
  collector << quote(value).to_s
@@ -10,24 +10,20 @@ module ActiveRecord
10
10
  "trilogy" => "abstract_mysql",
11
11
  }
12
12
 
13
- def self.require_adapter(adapter)
13
+ def self.require_adapter(adapter) # :nodoc:
14
14
  adapter = ADAPTER_EXTENSION_MAP.fetch(adapter, adapter)
15
15
  require File.join(ADAPTER_PATH, "#{adapter}_adapter")
16
16
  end
17
17
 
18
- def self.load_from_connection_pool(connection_pool)
18
+ def self.load_from_connection_pool(connection_pool) # :nodoc:
19
19
  require_adapter connection_pool.db_config.adapter
20
20
  end
21
21
  end
22
22
  end
23
23
 
24
24
  require "activerecord-updateinbulk/builder"
25
- require "activerecord-updateinbulk/arel/math"
26
- require "activerecord-updateinbulk/arel/nodes/least"
27
- require "activerecord-updateinbulk/arel/nodes/greatest"
28
25
  require "activerecord-updateinbulk/arel/nodes/values_table"
29
26
  require "activerecord-updateinbulk/arel/visitors/to_sql"
30
- require "activerecord-updateinbulk/arel/visitors/sqlite"
31
27
  require "activerecord-updateinbulk/arel/select_manager"
32
28
  require "activerecord-updateinbulk/relation"
33
29
  require "activerecord-updateinbulk/querying"
@@ -35,8 +31,8 @@ require "activerecord-updateinbulk/querying"
35
31
  require "activerecord-updateinbulk/adapters/abstract_adapter"
36
32
 
37
33
  module ActiveRecord::UpdateInBulk
38
- module ConnectionHandler
39
- def establish_connection(*args, **kwargs, &block)
34
+ module ConnectionHandler # :nodoc:
35
+ def establish_connection(*args, **kwargs, &block) # :nodoc:
40
36
  pool = super(*args, **kwargs, &block)
41
37
  ActiveRecord::UpdateInBulk.load_from_connection_pool pool
42
38
  pool
@@ -3,11 +3,13 @@
3
3
  require "active_support/core_ext/enumerable"
4
4
 
5
5
  module ActiveRecord::UpdateInBulk
6
- class Builder
7
- FORMULAS = %w[add subtract concat_append concat_prepend min max].freeze
6
+ class Builder # :nodoc:
7
+ FORMULAS = [:add, :subtract, :concat_append, :concat_prepend].freeze
8
+ SAFE_COMPARISON_TYPES = [:boolean, :string, :text, :integer, :float, :decimal].freeze
8
9
 
9
10
  class << self
10
11
  attr_accessor :values_table_name
12
+ attr_accessor :ignore_scope_order
11
13
 
12
14
  # Normalize all input formats into separated format [conditions, assigns].
13
15
  def normalize_updates(model, updates, values = nil)
@@ -46,19 +48,16 @@ module ActiveRecord::UpdateInBulk
46
48
  end
47
49
 
48
50
  def apply_formula(formula, lhs, rhs, model)
51
+ formula = formula.to_sym if formula.is_a?(String)
49
52
  case formula
50
- when "add"
53
+ when :add
51
54
  lhs + rhs
52
- when "subtract"
55
+ when :subtract
53
56
  lhs - rhs
54
- when "concat_append"
57
+ when :concat_append
55
58
  lhs.concat(rhs)
56
- when "concat_prepend"
59
+ when :concat_prepend
57
60
  rhs.concat(lhs)
58
- when "min"
59
- lhs.least(rhs)
60
- when "max"
61
- lhs.greatest(rhs)
62
61
  when Proc
63
62
  node = apply_proc_formula(formula, lhs, rhs, model)
64
63
  unless Arel.arel_node?(node)
@@ -66,7 +65,7 @@ module ActiveRecord::UpdateInBulk
66
65
  end
67
66
  node
68
67
  else
69
- rhs
68
+ raise ArgumentError, "Unknown formula: #{formula.inspect}"
70
69
  end
71
70
  end
72
71
 
@@ -113,6 +112,7 @@ module ActiveRecord::UpdateInBulk
113
112
  end
114
113
  end
115
114
  self.values_table_name = "t"
115
+ self.ignore_scope_order = true
116
116
 
117
117
  attr_reader :model, :connection
118
118
 
@@ -126,91 +126,102 @@ module ActiveRecord::UpdateInBulk
126
126
  resolve_attribute_aliases!
127
127
  resolve_read_and_write_keys!
128
128
  verify_read_and_write_keys!
129
+ unless simple_update?
130
+ detect_constant_columns!
131
+ serialize_values!
132
+ end
129
133
  end
130
134
 
131
135
  def build_arel
132
- values_table, bitmask_keys = build_values_table
133
- join_conditions = build_join_conditions(model.arel_table, values_table)
134
- set_assignments = build_set_assignments(model.arel_table, values_table, bitmask_keys)
135
- derived_table = typecast_values_table(values_table)
136
+ table = model.arel_table
137
+ values_table, bitmask_keys = build_values_table unless simple_update?
138
+ conditions = build_conditions(table, values_table)
139
+ set_assignments = build_set_assignments(table, values_table, bitmask_keys || Set.new)
140
+ derived_table = typecast_values_table(values_table) if values_table
136
141
 
137
- [derived_table, join_conditions, set_assignments]
142
+ [derived_table, conditions, set_assignments]
138
143
  end
139
144
 
140
145
  private
141
- attr_reader :read_keys, :write_keys
146
+ attr_reader :read_keys, :write_keys, :constant_assigns
142
147
 
143
148
  def optional_keys
144
149
  @optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
145
150
  end
146
151
 
147
152
  def timestamp_keys
148
- @timestamp_keys ||= @record_timestamps ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
153
+ @timestamp_keys ||= record_timestamps_enabled? ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
149
154
  end
150
155
 
151
- def build_values_table
152
- types = (read_keys | write_keys).index_with { |key| model.type_for_attribute(key) }
153
- rows, bitmask_keys = serialize_values_rows do |key, value|
154
- next value if Arel::Nodes::SqlLiteral === value
155
- ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
156
+ def simple_update?
157
+ @conditions.size == 1 && @formulas.empty?
158
+ end
159
+
160
+ def build_simple_conditions(table)
161
+ row_conditions = @conditions.first
162
+ read_keys.map do |key|
163
+ table[key].eq(cast_for_column(row_conditions.fetch(key), table[key]))
156
164
  end
157
- append_bitmask_column(rows, bitmask_keys) unless bitmask_keys.empty?
158
- values_table = Arel::Nodes::ValuesTable.new(self.class.values_table_name, rows, connection.values_table_default_column_names(rows.first.size))
159
- [values_table, bitmask_keys]
160
165
  end
161
166
 
162
- def build_join_conditions(table, values_table)
163
- read_keys.map.with_index do |key, index|
164
- table[key].eq(values_table[index])
167
+ def build_simple_assignments(table)
168
+ row_assigns = @assigns.first
169
+ write_keys.map do |key|
170
+ [table[key], cast_for_column(row_assigns.fetch(key), table[key])]
165
171
  end
166
172
  end
167
173
 
168
- def build_set_assignments(table, values_table, bitmask_keys)
169
- bitmask_functions = bitmask_keys.index_with.with_index(1) do |key, index|
170
- Arel::Nodes::NamedFunction.new("SUBSTRING", [values_table[-1], index, 1])
174
+ def detect_constant_columns!
175
+ @constant_assigns = {}
176
+ columns_hash = model.columns_hash
177
+
178
+ (write_keys - optional_keys).each do |key|
179
+ next if @formulas.key?(key) # need to pass Arel::Attribute as argument to formula
180
+ next unless SAFE_COMPARISON_TYPES.include?(columns_hash.fetch(key).type)
181
+ first = @assigns.first[key]
182
+ @constant_assigns[key] = first if @assigns.all? { |a| !opaque_value?(v = a[key]) && v == first }
171
183
  end
184
+ end
172
185
 
173
- set_assignments = write_keys.map.with_index do |key, index|
174
- formula = @formulas[key]
175
- lhs = table[key]
176
- rhs = values_table[index + read_keys.size]
177
- rhs = self.class.apply_formula(formula, lhs, rhs, model) if formula
178
- if function = bitmask_functions[key]
179
- rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(table[key])
180
- elsif optional_keys.include?(key)
181
- rhs = table.coalesce(rhs, table[key])
186
+ def serialize_values!
187
+ types = read_keys.index_with { |key| model.type_for_attribute(key) }
188
+ @conditions.each do |row|
189
+ row.each do |key, value|
190
+ next if opaque_value?(value)
191
+ row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
182
192
  end
183
- [table[key], rhs]
184
193
  end
185
-
186
- if timestamp_keys.any?
187
- set_assignments += timestamp_assignments(set_assignments)
194
+ types = write_keys.index_with { |key| model.type_for_attribute(key) }
195
+ @assigns.each do |row|
196
+ row.each do |key, value|
197
+ next if opaque_value?(value) || constant_assigns.key?(key)
198
+ row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
199
+ end
188
200
  end
189
-
190
- set_assignments
191
201
  end
192
202
 
193
- def typecast_values_table(values_table)
194
- columns_hash = model.columns_hash
195
- model_types = read_keys.to_a.concat(write_keys.to_a).map! { |key| columns_hash.fetch(key) }
196
- connection.typecast_values_table(values_table, model_types).alias(self.class.values_table_name)
203
+ def build_values_table
204
+ rows, bitmask_keys = build_values_table_rows
205
+ append_bitmask_column(rows, bitmask_keys) unless bitmask_keys.empty?
206
+ column_names = connection.values_table_default_column_names(rows.first.size)
207
+ values_table = Arel::Nodes::ValuesTable.new(self.class.values_table_name, rows, column_names)
208
+ [values_table, bitmask_keys]
197
209
  end
198
210
 
199
- def serialize_values_rows(&)
211
+ def build_values_table_rows
200
212
  bitmask_keys = Set.new
213
+ non_constant_write_keys = write_keys - constant_assigns.keys
201
214
 
202
- rows = @conditions.each_with_index.map do |row_conditions, row_index|
215
+ rows = @conditions.map.with_index do |row_conditions, row_index|
203
216
  row_assigns = @assigns[row_index]
204
- condition_values = read_keys.map do |key|
205
- yield(key, row_conditions[key])
206
- end
207
- write_values = write_keys.map do |key|
208
- next unless row_assigns.key?(key)
209
- value = yield(key, row_assigns[key])
217
+ row = row_conditions.fetch_values(*read_keys)
218
+ non_constant_write_keys.each do |key|
219
+ next row << nil unless row_assigns.key?(key)
220
+ value = row_assigns[key]
210
221
  bitmask_keys.add(key) if optional_keys.include?(key) && might_be_nil_value?(value)
211
- value
222
+ row << value
212
223
  end
213
- condition_values.concat(write_values)
224
+ row
214
225
  end
215
226
 
216
227
  [rows, bitmask_keys]
@@ -227,7 +238,72 @@ module ActiveRecord::UpdateInBulk
227
238
  end
228
239
  end
229
240
 
241
+ def build_conditions(table, values_table)
242
+ return build_simple_conditions(table) unless values_table
243
+
244
+ read_keys.map.with_index do |key, index|
245
+ table[key].eq(values_table[index])
246
+ end
247
+ end
248
+
249
+ def build_set_assignments(table, values_table, bitmask_keys)
250
+ set_assignments = if values_table
251
+ build_join_assignments(table, values_table, bitmask_keys)
252
+ else
253
+ build_simple_assignments(table)
254
+ end
255
+
256
+ if timestamp_keys.any?
257
+ # Timestamp assignments precede data assignments to increase the
258
+ # chance MySQL will actually run them against the original data.
259
+ set_assignments = timestamp_assignments(set_assignments) + set_assignments
260
+ end
261
+
262
+ set_assignments
263
+ end
264
+
265
+ def build_join_assignments(table, values_table, bitmask_keys)
266
+ column = read_keys.size
267
+
268
+ bitmask_functions = bitmask_keys.index_with.with_index(1) do |key, index|
269
+ Arel::Nodes::NamedFunction.new("SUBSTRING", [values_table[-1], index, 1])
270
+ end
271
+
272
+ write_keys.map do |key|
273
+ formula = @formulas[key]
274
+ lhs = table[key]
275
+
276
+ if constant_assigns.key?(key)
277
+ rhs = Arel::Nodes::Casted.new(constant_assigns[key], lhs)
278
+ else
279
+ rhs = values_table[column]
280
+ column += 1
281
+ rhs = self.class.apply_formula(formula, lhs, rhs, model) if formula
282
+ end
283
+
284
+ if function = bitmask_functions[key]
285
+ rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(lhs)
286
+ elsif optional_keys.include?(key)
287
+ rhs = table.coalesce(rhs, lhs)
288
+ end
289
+ [lhs, rhs]
290
+ end
291
+ end
292
+
293
+ def typecast_values_table(values_table)
294
+ variable_keys = read_keys + write_keys.reject { |k| constant_assigns.key?(k) }
295
+ columns_hash = model.columns_hash
296
+ model_types = variable_keys.map! { |key| columns_hash.fetch(key) }
297
+ connection.typecast_values_table(values_table, model_types).alias(self.class.values_table_name)
298
+ end
299
+
230
300
  def timestamp_assignments(set_assignments)
301
+ if always_record_timestamps?
302
+ return timestamp_keys.map do |key|
303
+ [model.arel_table[key], connection.high_precision_current_timestamp]
304
+ end
305
+ end
306
+
231
307
  case_conditions = set_assignments.map do |left, right|
232
308
  left.is_not_distinct_from(right)
233
309
  end
@@ -240,17 +316,33 @@ module ActiveRecord::UpdateInBulk
240
316
  end
241
317
  end
242
318
 
319
+ def record_timestamps_enabled?
320
+ !@record_timestamps.nil? && @record_timestamps != false
321
+ end
322
+
323
+ def always_record_timestamps?
324
+ @record_timestamps == :always
325
+ end
326
+
243
327
  # When you assign a value to NULL, we need to use a bitmask to distinguish that
244
328
  # row in the values table from rows where the column is not to be assigned at all.
245
329
  def might_be_nil_value?(value)
246
- value.nil? || value.is_a?(Arel::Nodes::SqlLiteral) || value.is_a?(Arel::Nodes::BindParam)
330
+ value.nil? || opaque_value?(value)
331
+ end
332
+
333
+ def opaque_value?(value)
334
+ Arel.arel_node?(value)
335
+ end
336
+
337
+ def cast_for_column(value, column)
338
+ opaque_value?(value) ? value : Arel::Nodes::Casted.new(value, column)
247
339
  end
248
340
 
249
341
  def normalize_formulas(formulas)
250
342
  return {} if formulas.blank?
251
343
 
252
344
  normalized = formulas.to_h do |key, value|
253
- [key.to_s, value.is_a?(Proc) ? value : value.to_s]
345
+ [key.to_s, value.is_a?(Proc) ? value : value.to_sym]
254
346
  end
255
347
  invalid = normalized.values.reject { |v| v.is_a?(Proc) } - FORMULAS
256
348
  if invalid.any?
@@ -271,7 +363,7 @@ module ActiveRecord::UpdateInBulk
271
363
 
272
364
  def resolve_read_and_write_keys!
273
365
  @read_keys = @conditions.first.keys.to_set
274
- @write_keys = @assigns.flat_map(&:keys).to_set
366
+ @write_keys = @assigns.reduce(Set.new) { |set, row| set.merge(row.keys) }
275
367
  end
276
368
 
277
369
  def verify_read_and_write_keys!
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecord::UpdateInBulk
4
- module Querying
5
- def update_in_bulk(...)
4
+ module Querying # :nodoc:
5
+ def update_in_bulk(...) # :nodoc:
6
6
  all.update_in_bulk(...)
7
7
  end
8
8
  end
@@ -4,8 +4,30 @@ require "rails/railtie"
4
4
 
5
5
  module ActiveRecord
6
6
  module UpdateInBulk
7
+ # Railtie integration for configuration values consumed by this gem.
8
+ #
9
+ # ==== Configuration
10
+ #
11
+ # Configure these in a Rails initializer (for example
12
+ # <tt>config/initializers/update_in_bulk.rb</tt>):
13
+ #
14
+ # Rails.application.config.active_record_update_in_bulk.values_table_alias = "vals"
15
+ # Rails.application.config.active_record_update_in_bulk.ignore_scope_order = true
16
+ #
17
+ # [config.active_record_update_in_bulk.values_table_alias]
18
+ # Optional string alias to use for the generated VALUES table.
19
+ # Defaults to <tt>"t"</tt>.
20
+ #
21
+ # [config.active_record_update_in_bulk.ignore_scope_order]
22
+ # Whether <tt>Relation#update_in_bulk</tt> should ignore any ORDER BY scope
23
+ # on the input relation. Necessary for invoking the method on scope-ordered
24
+ # associations, or models with a default scope that includes an order.
25
+ #
26
+ # * <tt>true</tt> (default): ORDER BY scopes are stripped.
27
+ # * <tt>false</tt>: ordered relations raise NotImplementedError.
7
28
  class Railtie < Rails::Railtie
8
29
  config.active_record_update_in_bulk = ActiveSupport::OrderedOptions.new
30
+ config.active_record_update_in_bulk.ignore_scope_order = true
9
31
 
10
32
  initializer "active_record_update_in_bulk.values_table_alias", after: :load_config_initializers do |app|
11
33
  if (bulk_alias = app.config.active_record_update_in_bulk.values_table_alias)
@@ -15,6 +37,14 @@ module ActiveRecord
15
37
  ActiveRecord::UpdateInBulk::Builder.values_table_name = bulk_alias
16
38
  end
17
39
  end
40
+
41
+ initializer "active_record_update_in_bulk.ignore_scope_order", after: :load_config_initializers do |app|
42
+ ignore_scope_order = app.config.active_record_update_in_bulk.ignore_scope_order
43
+ unless ignore_scope_order == true || ignore_scope_order == false
44
+ raise ArgumentError, "ignore_scope_order must be true or false"
45
+ end
46
+ ActiveRecord::UpdateInBulk::Builder.ignore_scope_order = ignore_scope_order
47
+ end
18
48
  end
19
49
  end
20
50
  end
@@ -51,13 +51,17 @@ module ActiveRecord::UpdateInBulk
51
51
  # To override this and force automatic setting of timestamp columns one
52
52
  # way or the other, pass <tt>:record_timestamps</tt>.
53
53
  #
54
+ # Pass <tt>record_timestamps: :always</tt> to always assign timestamp
55
+ # columns to the current database timestamp (without change-detection
56
+ # CASE logic).
57
+ #
54
58
  # [:formulas]
55
59
  # A hash of column names to formula identifiers or Procs. Instead of
56
60
  # a simple assignment, the column is set to an expression that can
57
61
  # reference both the current selected row value and the incoming value.
58
62
  #
59
- # Built-in formulas: <tt>:add</tt>, <tt>:subtract</tt>, <tt>:min</tt>,
60
- # <tt>:max</tt>, <tt>:concat_append</tt>, <tt>:concat_prepend</tt>.
63
+ # Built-in formulas: <tt>:add</tt>, <tt>:subtract</tt>,
64
+ # <tt>:concat_append</tt>, <tt>:concat_prepend</tt>.
61
65
  #
62
66
  # Inventory.update_in_bulk({
63
67
  # "Christmas balls" => { quantity: 73 },
@@ -68,8 +72,8 @@ module ActiveRecord::UpdateInBulk
68
72
  # <tt>(lhs, rhs)</tt> or <tt>(lhs, rhs, model)</tt> and returns an
69
73
  # Arel node:
70
74
  #
71
- # add_capped = ->(lhs, rhs) { lhs.least(lhs + rhs) }
72
- # Inventory.update_in_bulk(updates, formulas: { quantity: add_capped })
75
+ # add_tax = ->(lhs, rhs) { lhs + rhs + 1 }
76
+ # Inventory.update_in_bulk(updates, formulas: { quantity: add_tax })
73
77
  #
74
78
  # ==== Examples
75
79
  #
@@ -87,8 +91,11 @@ module ActiveRecord::UpdateInBulk
87
91
  # })
88
92
  #
89
93
  def update_in_bulk(updates, values = nil, record_timestamps: nil, formulas: nil)
90
- unless limit_value.nil? && offset_value.nil? && order_values.empty? && group_values.empty? && having_clause.empty?
91
- raise NotImplementedError, "No support to update grouped or ordered relations (offset, limit, order, group, having clauses)"
94
+ unless limit_value.nil? && offset_value.nil? && group_values.empty? && having_clause.empty?
95
+ raise NotImplementedError, "No support to update relations with offset, limit, group, or having clauses"
96
+ end
97
+ if order_values.any? && !Builder.ignore_scope_order
98
+ raise NotImplementedError, "No support to update ordered relations (order clause)"
92
99
  end
93
100
 
94
101
  conditions, assigns = Builder.normalize_updates(model, updates, values)
@@ -101,8 +108,9 @@ module ActiveRecord::UpdateInBulk
101
108
 
102
109
  arel = eager_loading? ? apply_join_dependency.arel : arel()
103
110
  arel.source.left = table
111
+ arel.ast.orders = [] if Builder.ignore_scope_order
104
112
 
105
- values_table, join_conditions, set_assignments = Builder.new(
113
+ values_table, conditions, set_assignments = Builder.new(
106
114
  self,
107
115
  c,
108
116
  conditions,
@@ -110,7 +118,11 @@ module ActiveRecord::UpdateInBulk
110
118
  record_timestamps:,
111
119
  formulas:
112
120
  ).build_arel
113
- arel = arel.join(values_table).on(*join_conditions)
121
+ if values_table
122
+ arel = arel.join(values_table).on(*conditions)
123
+ else
124
+ conditions.each { |condition| arel.where(condition) }
125
+ end
114
126
 
115
127
  key = if model.composite_primary_key?
116
128
  primary_key.map { |pk| table[pk] }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module UpdateInBulk
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-updateinbulk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Carvalho
@@ -52,12 +52,8 @@ files:
52
52
  - lib/activerecord-updateinbulk/adapters/abstract_mysql_adapter.rb
53
53
  - lib/activerecord-updateinbulk/adapters/postgresql_adapter.rb
54
54
  - lib/activerecord-updateinbulk/adapters/sqlite3_adapter.rb
55
- - lib/activerecord-updateinbulk/arel/math.rb
56
- - lib/activerecord-updateinbulk/arel/nodes/greatest.rb
57
- - lib/activerecord-updateinbulk/arel/nodes/least.rb
58
55
  - lib/activerecord-updateinbulk/arel/nodes/values_table.rb
59
56
  - lib/activerecord-updateinbulk/arel/select_manager.rb
60
- - lib/activerecord-updateinbulk/arel/visitors/sqlite.rb
61
57
  - lib/activerecord-updateinbulk/arel/visitors/to_sql.rb
62
58
  - lib/activerecord-updateinbulk/base.rb
63
59
  - lib/activerecord-updateinbulk/builder.rb
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Arel
4
- module Math
5
- def least(other)
6
- lhs = is_a?(Arel::Nodes::Least) ? self.expressions : [self]
7
- rhs = other.is_a?(Arel::Nodes::Least) ? other.expressions : [other]
8
- Arel::Nodes::Least.new(lhs + rhs)
9
- end
10
-
11
- def greatest(other)
12
- lhs = is_a?(Arel::Nodes::Greatest) ? self.expressions : [self]
13
- rhs = other.is_a?(Arel::Nodes::Greatest) ? other.expressions : [other]
14
- Arel::Nodes::Greatest.new(lhs + rhs)
15
- end
16
- end
17
- end
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Arel::Nodes
4
- class Greatest < Function
5
- end
6
- end
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Arel::Nodes
4
- class Least < Function
5
- end
6
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveRecord::UpdateInBulk
4
- module SQLiteToSql
5
- private
6
- def visit_Arel_Nodes_Least(o, collector)
7
- collector << "MIN("
8
- inject_join(o.expressions, collector, ", ")
9
- collector << ")"
10
- end
11
-
12
- def visit_Arel_Nodes_Greatest(o, collector)
13
- collector << "MAX("
14
- inject_join(o.expressions, collector, ", ")
15
- collector << ")"
16
- end
17
- end
18
- end
19
-
20
- Arel::Visitors::SQLite.prepend(ActiveRecord::UpdateInBulk::SQLiteToSql)