activerecord-updateinbulk 0.1.2 → 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: 3bac8ddb8a7317ff277d0afbf77432ed979aa3246ec4030fd62f64ef64c98127
4
- data.tar.gz: f09a62eda8673ad252d49038a8f1c3605e435b04121069b531f8d56fef48a919
3
+ metadata.gz: 25ba0ab087a02a9c275429d50daa9b31bc2bc93fe4ae40c726402f4fd2f21ec2
4
+ data.tar.gz: d32540835f40aaaf11aa0180d9f49763c775ff7b55287dec5cd9db568b20f45f
5
5
  SHA512:
6
- metadata.gz: 3f8e68e4bcc3704ffa56960fb9f12c4e49156c027f156eea4b356e710916e839a99e398e8aaddad9d7cb5e93b28a32bfd380e0f9372967c08bf76377fd7fcf9d
7
- data.tar.gz: b6ec0dbcb9a18e1182aab529ca685a2edf441d030a28c8bc0951a79b8dcfe4f5b4c4822724d9e3fc01de7eeda387e5c0c25636ff9ca6c4d0504fe341165a1d26
6
+ metadata.gz: 2dee9dd00a22a4bebf1d692d926ce2dfafaf2730b3163ee384dda5038616fbef79812caba778280d7ed36523e5f0433dac5cbfc19667c8d038a79bf10fc6db0e
7
+ data.tar.gz: 8273e2806d3dfceca28214e461b0a6a7954d613e2fc9dfeebc4c754e1582121473a87817fdbc14498d534429163f3554f82ba46eb6b10c1a322aedbcffc558ae
data/README.md CHANGED
@@ -59,11 +59,26 @@ Order.joins(:items).where(items: { status: :shipped }).update_in_bulk({
59
59
  })
60
60
  ```
61
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
+
62
76
  ### Record timestamps
63
77
 
64
78
  By default `update_in_bulk` implicitly bumps update timestamps similar to `upsert_all`.
65
79
  - If the model has `updated_at`/`updated_on`, these are bumped *iff the row actually changed*.
66
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`.
67
82
  - The `updated_at` columns can also be manually assigned, this disables the implicit bump behaviour.
68
83
 
69
84
  ```ruby
@@ -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,97 +126,155 @@ module ActiveRecord::UpdateInBulk
126
126
  resolve_attribute_aliases!
127
127
  resolve_read_and_write_keys!
128
128
  verify_read_and_write_keys!
129
- serialize_values!
130
- detect_constant_columns!
129
+ unless simple_update?
130
+ detect_constant_columns!
131
+ serialize_values!
132
+ end
131
133
  end
132
134
 
133
135
  def build_arel
134
- values_table, bitmask_keys = build_values_table
135
- join_conditions = build_join_conditions(model.arel_table, values_table)
136
- set_assignments = build_set_assignments(model.arel_table, values_table, bitmask_keys)
137
- 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
138
141
 
139
- [derived_table, join_conditions, set_assignments]
142
+ [derived_table, conditions, set_assignments]
140
143
  end
141
144
 
142
145
  private
143
- attr_reader :read_keys, :write_keys, :constant_conditions, :constant_assigns
146
+ attr_reader :read_keys, :write_keys, :constant_assigns
147
+
148
+ def optional_keys
149
+ @optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
150
+ end
151
+
152
+ def timestamp_keys
153
+ @timestamp_keys ||= record_timestamps_enabled? ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
154
+ end
155
+
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]))
164
+ end
165
+ end
166
+
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])]
171
+ end
172
+ end
173
+
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 }
183
+ end
184
+ end
144
185
 
145
186
  def serialize_values!
146
- types = (read_keys | write_keys).index_with { |key| model.type_for_attribute(key) }
187
+ types = read_keys.index_with { |key| model.type_for_attribute(key) }
147
188
  @conditions.each do |row|
148
189
  row.each do |key, value|
149
190
  next if opaque_value?(value)
150
191
  row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
151
192
  end
152
193
  end
194
+ types = write_keys.index_with { |key| model.type_for_attribute(key) }
153
195
  @assigns.each do |row|
154
196
  row.each do |key, value|
155
- next if opaque_value?(value)
197
+ next if opaque_value?(value) || constant_assigns.key?(key)
156
198
  row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
157
199
  end
158
200
  end
159
201
  end
160
202
 
161
- def detect_constant_columns!
162
- @constant_conditions = {}
163
- @constant_assigns = {}
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]
209
+ end
164
210
 
165
- read_keys.each do |key|
166
- first = @conditions.first.fetch(key)
167
- @constant_conditions[key] = first if @conditions.all? { |c| !opaque_value?(v = c.fetch(key)) && v == first }
168
- end
169
- # We can't drop all the conditions columns
170
- @constant_conditions.delete(read_keys.first) if @constant_conditions.size == read_keys.size
211
+ def build_values_table_rows
212
+ bitmask_keys = Set.new
213
+ non_constant_write_keys = write_keys - constant_assigns.keys
171
214
 
172
- (write_keys - optional_keys).each do |key|
173
- next if @formulas.key?(key) # need to pass Arel::Attribute as argument to formula
174
- first = @assigns.first[key]
175
- @constant_assigns[key] = first if @assigns.all? { |a| !opaque_value?(v = a[key]) && v == first }
215
+ rows = @conditions.map.with_index do |row_conditions, row_index|
216
+ row_assigns = @assigns[row_index]
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]
221
+ bitmask_keys.add(key) if optional_keys.include?(key) && might_be_nil_value?(value)
222
+ row << value
223
+ end
224
+ row
176
225
  end
177
- end
178
226
 
179
- def optional_keys
180
- @optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
227
+ [rows, bitmask_keys]
181
228
  end
182
229
 
183
- def timestamp_keys
184
- @timestamp_keys ||= @record_timestamps ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
230
+ def append_bitmask_column(rows, bitmask_keys)
231
+ rows.each_with_index do |row, row_index|
232
+ row_assigns = @assigns[row_index]
233
+ bitmask = "0" * bitmask_keys.size
234
+ bitmask_keys.each_with_index do |key, index|
235
+ bitmask[index] = "1" if row_assigns.key?(key)
236
+ end
237
+ row.push(bitmask)
238
+ end
185
239
  end
186
240
 
187
- def build_values_table
188
- rows, bitmask_keys = serialize_values_rows
189
- append_bitmask_column(rows, bitmask_keys) unless bitmask_keys.empty?
190
- values_table = Arel::Nodes::ValuesTable.new(self.class.values_table_name, rows, connection.values_table_default_column_names(rows.first.size))
191
- [values_table, bitmask_keys]
192
- end
241
+ def build_conditions(table, values_table)
242
+ return build_simple_conditions(table) unless values_table
193
243
 
194
- def build_join_conditions(table, values_table)
195
- variable_index = 0
196
- read_keys.map do |key|
197
- if constant_conditions.key?(key)
198
- table[key].eq(Arel::Nodes::Casted.new(constant_conditions[key], table[key]))
199
- else
200
- condition = table[key].eq(values_table[variable_index])
201
- variable_index += 1
202
- condition
203
- end
244
+ read_keys.map.with_index do |key, index|
245
+ table[key].eq(values_table[index])
204
246
  end
205
247
  end
206
248
 
207
249
  def build_set_assignments(table, values_table, bitmask_keys)
208
- column = read_keys.count { |k| !constant_conditions.key?(k) }
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
209
267
 
210
268
  bitmask_functions = bitmask_keys.index_with.with_index(1) do |key, index|
211
269
  Arel::Nodes::NamedFunction.new("SUBSTRING", [values_table[-1], index, 1])
212
270
  end
213
271
 
214
- set_assignments = write_keys.map do |key|
272
+ write_keys.map do |key|
215
273
  formula = @formulas[key]
216
274
  lhs = table[key]
217
275
 
218
276
  if constant_assigns.key?(key)
219
- rhs = Arel::Nodes::Casted.new(constant_assigns[key], table[key])
277
+ rhs = Arel::Nodes::Casted.new(constant_assigns[key], lhs)
220
278
  else
221
279
  rhs = values_table[column]
222
280
  column += 1
@@ -224,64 +282,28 @@ module ActiveRecord::UpdateInBulk
224
282
  end
225
283
 
226
284
  if function = bitmask_functions[key]
227
- rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(table[key])
285
+ rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(lhs)
228
286
  elsif optional_keys.include?(key)
229
- rhs = table.coalesce(rhs, table[key])
287
+ rhs = table.coalesce(rhs, lhs)
230
288
  end
231
- [table[key], rhs]
289
+ [lhs, rhs]
232
290
  end
233
-
234
- if timestamp_keys.any?
235
- # Timestamp assignments precede data assignments to increase the
236
- # change MySQL will actually run them against the original data.
237
- set_assignments = timestamp_assignments(set_assignments) + set_assignments
238
- end
239
-
240
- set_assignments
241
291
  end
242
292
 
243
293
  def typecast_values_table(values_table)
244
- variable_keys = read_keys.reject { |k| constant_conditions.key?(k) } + write_keys.reject { |k| constant_assigns.key?(k) }
294
+ variable_keys = read_keys + write_keys.reject { |k| constant_assigns.key?(k) }
245
295
  columns_hash = model.columns_hash
246
296
  model_types = variable_keys.map! { |key| columns_hash.fetch(key) }
247
297
  connection.typecast_values_table(values_table, model_types).alias(self.class.values_table_name)
248
298
  end
249
299
 
250
- def serialize_values_rows
251
- bitmask_keys = Set.new
252
-
253
- rows = @conditions.each_with_index.map do |row_conditions, row_index|
254
- row_assigns = @assigns[row_index]
255
- row = []
256
- read_keys.each do |key|
257
- next if constant_conditions.key?(key)
258
- row << row_conditions[key]
259
- end
260
- write_keys.each do |key|
261
- next if constant_assigns.key?(key)
262
- next row << nil unless row_assigns.key?(key)
263
- value = row_assigns[key]
264
- bitmask_keys.add(key) if optional_keys.include?(key) && might_be_nil_value?(value)
265
- row << value
266
- end
267
- row
268
- end
269
-
270
- [rows, bitmask_keys]
271
- end
272
-
273
- def append_bitmask_column(rows, bitmask_keys)
274
- rows.each_with_index do |row, row_index|
275
- row_assigns = @assigns[row_index]
276
- bitmask = "0" * bitmask_keys.size
277
- bitmask_keys.each_with_index do |key, index|
278
- bitmask[index] = "1" if row_assigns.key?(key)
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]
279
304
  end
280
- row.push(bitmask)
281
305
  end
282
- end
283
306
 
284
- def timestamp_assignments(set_assignments)
285
307
  case_conditions = set_assignments.map do |left, right|
286
308
  left.is_not_distinct_from(right)
287
309
  end
@@ -294,21 +316,33 @@ module ActiveRecord::UpdateInBulk
294
316
  end
295
317
  end
296
318
 
297
- # When you assign a value to NULL, we need to use a bitmask to distinguish
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
+
327
+ # When you assign a value to NULL, we need to use a bitmask to distinguish that
298
328
  # row in the values table from rows where the column is not to be assigned at all.
299
329
  def might_be_nil_value?(value)
300
330
  value.nil? || opaque_value?(value)
301
331
  end
302
332
 
303
333
  def opaque_value?(value)
304
- value.is_a?(Arel::Nodes::SqlLiteral) || value.is_a?(Arel::Nodes::BindParam)
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)
305
339
  end
306
340
 
307
341
  def normalize_formulas(formulas)
308
342
  return {} if formulas.blank?
309
343
 
310
344
  normalized = formulas.to_h do |key, value|
311
- [key.to_s, value.is_a?(Proc) ? value : value.to_s]
345
+ [key.to_s, value.is_a?(Proc) ? value : value.to_sym]
312
346
  end
313
347
  invalid = normalized.values.reject { |v| v.is_a?(Proc) } - FORMULAS
314
348
  if invalid.any?
@@ -329,7 +363,7 @@ module ActiveRecord::UpdateInBulk
329
363
 
330
364
  def resolve_read_and_write_keys!
331
365
  @read_keys = @conditions.first.keys.to_set
332
- @write_keys = @assigns.flat_map(&:keys).to_set
366
+ @write_keys = @assigns.reduce(Set.new) { |set, row| set.merge(row.keys) }
333
367
  end
334
368
 
335
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.2"
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.2
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)