activerecord-updateinbulk 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bac8ddb8a7317ff277d0afbf77432ed979aa3246ec4030fd62f64ef64c98127
4
- data.tar.gz: f09a62eda8673ad252d49038a8f1c3605e435b04121069b531f8d56fef48a919
3
+ metadata.gz: 1a844813b98d092d304542eb4f8c3ffc6b99a50f7510d430bef229e47c969415
4
+ data.tar.gz: e83aa71485ad9255b977b96afe9a8ec55820eda485d489d16e30629c1a79a971
5
5
  SHA512:
6
- metadata.gz: 3f8e68e4bcc3704ffa56960fb9f12c4e49156c027f156eea4b356e710916e839a99e398e8aaddad9d7cb5e93b28a32bfd380e0f9372967c08bf76377fd7fcf9d
7
- data.tar.gz: b6ec0dbcb9a18e1182aab529ca685a2edf441d030a28c8bc0951a79b8dcfe4f5b4c4822724d9e3fc01de7eeda387e5c0c25636ff9ca6c4d0504fe341165a1d26
6
+ metadata.gz: 45d04870ea3981cc5bb3533572f8fd412cc442bfbde77163d2d78bb47af3e3234658db990afdea09fc754f6d492ed400bc50f7f23508f01a82614f1803fae05e
7
+ data.tar.gz: 33dfb33a7963b357eb8da64afc3353e671fa33cab926e8bf3b9380ccecd47dbae1360f776159e35dc02a44c0c8f98721d7ed58306eebc456bf89673b0fc14900
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
@@ -12,8 +12,9 @@ module ActiveRecord::UpdateInBulk
12
12
  case column
13
13
  when ActiveRecord::ConnectionAdapters::PostgreSQL::Column
14
14
  if SAFE_TYPES_FOR_VALUES_TABLE.exclude?(column.type) ||
15
+ column.array ||
15
16
  values_table.rows.all? { |row| row[index].nil? }
16
- column.sql_type
17
+ column.sql_type_metadata.sql_type
17
18
  end
18
19
  when Arel::Nodes::SqlLiteral, nil
19
20
  column
@@ -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,32 @@ 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
+
22
+ def self.register_formula(name, &formula)
23
+ Builder.register_formula(name, &formula)
24
+ end
25
+
26
+ def self.unregister_formula(name)
27
+ Builder.unregister_formula(name)
28
+ end
29
+
30
+ def self.registered_formula?(name)
31
+ Builder.registered_formula?(name)
32
+ end
21
33
  end
22
34
  end
23
35
 
24
36
  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
37
  require "activerecord-updateinbulk/arel/nodes/values_table"
29
38
  require "activerecord-updateinbulk/arel/visitors/to_sql"
30
- require "activerecord-updateinbulk/arel/visitors/sqlite"
31
39
  require "activerecord-updateinbulk/arel/select_manager"
32
40
  require "activerecord-updateinbulk/relation"
33
41
  require "activerecord-updateinbulk/querying"
@@ -35,8 +43,8 @@ require "activerecord-updateinbulk/querying"
35
43
  require "activerecord-updateinbulk/adapters/abstract_adapter"
36
44
 
37
45
  module ActiveRecord::UpdateInBulk
38
- module ConnectionHandler
39
- def establish_connection(*args, **kwargs, &block)
46
+ module ConnectionHandler # :nodoc:
47
+ def establish_connection(*args, **kwargs, &block) # :nodoc:
40
48
  pool = super(*args, **kwargs, &block)
41
49
  ActiveRecord::UpdateInBulk.load_from_connection_pool pool
42
50
  pool
@@ -3,11 +3,31 @@
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
+ SAFE_COMPARISON_TYPES = [:boolean, :string, :text, :integer, :float, :decimal].freeze
8
8
 
9
9
  class << self
10
10
  attr_accessor :values_table_name
11
+ attr_accessor :ignore_scope_order
12
+
13
+ def register_formula(name, &formula)
14
+ raise ArgumentError, "Missing block" unless formula
15
+
16
+ name = name.to_sym
17
+ if registered_formulas.key?(name)
18
+ raise ArgumentError, "Formula already registered: #{name.inspect}"
19
+ end
20
+
21
+ registered_formulas[name] = formula
22
+ end
23
+
24
+ def unregister_formula(name)
25
+ registered_formulas.delete(name.to_sym)
26
+ end
27
+
28
+ def registered_formula?(name)
29
+ registered_formulas.key?(name.to_sym)
30
+ end
11
31
 
12
32
  # Normalize all input formats into separated format [conditions, assigns].
13
33
  def normalize_updates(model, updates, values = nil)
@@ -46,42 +66,37 @@ module ActiveRecord::UpdateInBulk
46
66
  end
47
67
 
48
68
  def apply_formula(formula, lhs, rhs, model)
49
- case formula
50
- when "add"
51
- lhs + rhs
52
- when "subtract"
53
- lhs - rhs
54
- when "concat_append"
55
- lhs.concat(rhs)
56
- when "concat_prepend"
57
- rhs.concat(lhs)
58
- when "min"
59
- lhs.least(rhs)
60
- when "max"
61
- lhs.greatest(rhs)
62
- when Proc
63
- node = apply_proc_formula(formula, lhs, rhs, model)
64
- unless Arel.arel_node?(node)
65
- raise ArgumentError, "Custom formula must return an Arel node"
66
- end
67
- node
69
+ formula_proc = case formula
70
+ when Proc then formula
68
71
  else
69
- rhs
72
+ registered_formulas.fetch(formula.to_sym) do
73
+ raise ArgumentError, "Unknown formula: #{formula.inspect}"
74
+ end
70
75
  end
76
+
77
+ node = apply_proc_formula(formula_proc, lhs, rhs, model)
78
+ raise ArgumentError, "Custom formula must return an Arel node" unless Arel.arel_node?(node)
79
+
80
+ node
71
81
  end
72
82
 
73
83
  def apply_proc_formula(formula, lhs, rhs, model)
74
84
  case formula.arity
75
- when 2
76
- formula.call(lhs, rhs)
77
- when 3
78
- formula.call(lhs, rhs, model)
79
- else
80
- raise ArgumentError, "Custom formula must accept 2 or 3 arguments"
85
+ when 2 then formula.call(lhs, rhs)
86
+ else formula.call(lhs, rhs, model)
81
87
  end
82
88
  end
83
89
 
84
90
  private
91
+ def registered_formulas
92
+ @registered_formulas ||= {
93
+ add: lambda { |lhs, rhs| lhs + rhs },
94
+ subtract: lambda { |lhs, rhs| lhs - rhs },
95
+ concat_append: lambda { |lhs, rhs| lhs.concat(rhs) },
96
+ concat_prepend: lambda { |lhs, rhs| rhs.concat(lhs) }
97
+ }
98
+ end
99
+
85
100
  def normalize_conditions(model, conditions)
86
101
  if conditions.is_a?(Hash)
87
102
  conditions
@@ -113,6 +128,7 @@ module ActiveRecord::UpdateInBulk
113
128
  end
114
129
  end
115
130
  self.values_table_name = "t"
131
+ self.ignore_scope_order = true
116
132
 
117
133
  attr_reader :model, :connection
118
134
 
@@ -127,96 +143,150 @@ module ActiveRecord::UpdateInBulk
127
143
  resolve_read_and_write_keys!
128
144
  verify_read_and_write_keys!
129
145
  serialize_values!
130
- detect_constant_columns!
146
+ detect_constant_columns! unless simple_update?
131
147
  end
132
148
 
133
149
  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)
150
+ table = model.arel_table
151
+ values_table, bitmask_keys = build_values_table unless simple_update?
152
+ conditions = build_conditions(table, values_table)
153
+ set_assignments = build_set_assignments(table, values_table, bitmask_keys || Set.new)
154
+ derived_table = typecast_values_table(values_table) if values_table
138
155
 
139
- [derived_table, join_conditions, set_assignments]
156
+ [derived_table, conditions, set_assignments]
140
157
  end
141
158
 
142
159
  private
143
- attr_reader :read_keys, :write_keys, :constant_conditions, :constant_assigns
160
+ attr_reader :read_keys, :write_keys, :constant_assigns
161
+
162
+ def optional_keys
163
+ @optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
164
+ end
165
+
166
+ def timestamp_keys
167
+ @timestamp_keys ||= record_timestamps_enabled? ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
168
+ end
169
+
170
+ def simple_update?
171
+ @conditions.size == 1 && @formulas.empty?
172
+ end
173
+
174
+ def build_simple_conditions(table)
175
+ row_conditions = @conditions.first
176
+ read_keys.map do |key|
177
+ table[key].eq(quoted_value(row_conditions.fetch(key)))
178
+ end
179
+ end
180
+
181
+ def build_simple_assignments(table)
182
+ row_assigns = @assigns.first
183
+ write_keys.map do |key|
184
+ [table[key], quoted_value(row_assigns.fetch(key))]
185
+ end
186
+ end
187
+
188
+ def detect_constant_columns!
189
+ @constant_assigns = {}
190
+
191
+ (write_keys - optional_keys).each do |key|
192
+ next if @formulas.key?(key) # need to pass Arel::Attribute as argument to formula
193
+ first = @assigns.first[key]
194
+ @constant_assigns[key] = first if @assigns.all? { |a| !Arel.arel_node?(v = a[key]) && v == first }
195
+ end
196
+ end
144
197
 
145
198
  def serialize_values!
146
- types = (read_keys | write_keys).index_with { |key| model.type_for_attribute(key) }
199
+ types = read_keys.index_with { |key| model.type_for_attribute(key) }
147
200
  @conditions.each do |row|
148
201
  row.each do |key, value|
149
- next if opaque_value?(value)
202
+ next if Arel.arel_node?(value)
150
203
  row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
151
204
  end
152
205
  end
206
+ types = write_keys.index_with { |key| model.type_for_attribute(key) }
153
207
  @assigns.each do |row|
154
208
  row.each do |key, value|
155
- next if opaque_value?(value)
209
+ next if Arel.arel_node?(value)
156
210
  row[key] = ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
157
211
  end
158
212
  end
159
213
  end
160
214
 
161
- def detect_constant_columns!
162
- @constant_conditions = {}
163
- @constant_assigns = {}
215
+ def build_values_table
216
+ rows, bitmask_keys = build_values_table_rows
217
+ append_bitmask_column(rows, bitmask_keys) unless bitmask_keys.empty?
218
+ column_names = connection.values_table_default_column_names(rows.first.size)
219
+ values_table = Arel::Nodes::ValuesTable.new(self.class.values_table_name, rows, column_names)
220
+ [values_table, bitmask_keys]
221
+ end
164
222
 
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
223
+ def build_values_table_rows
224
+ bitmask_keys = Set.new
225
+ non_constant_write_keys = write_keys - constant_assigns.keys
171
226
 
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 }
227
+ rows = @conditions.map.with_index do |row_conditions, row_index|
228
+ row_assigns = @assigns[row_index]
229
+ row = row_conditions.fetch_values(*read_keys)
230
+ non_constant_write_keys.each do |key|
231
+ next row << nil unless row_assigns.key?(key)
232
+ value = row_assigns[key]
233
+ bitmask_keys.add(key) if optional_keys.include?(key) && might_be_nil_value?(value)
234
+ row << value
235
+ end
236
+ row
176
237
  end
177
- end
178
238
 
179
- def optional_keys
180
- @optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
239
+ [rows, bitmask_keys]
181
240
  end
182
241
 
183
- def timestamp_keys
184
- @timestamp_keys ||= @record_timestamps ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
242
+ def append_bitmask_column(rows, bitmask_keys)
243
+ rows.each_with_index do |row, row_index|
244
+ row_assigns = @assigns[row_index]
245
+ bitmask = "0" * bitmask_keys.size
246
+ bitmask_keys.each_with_index do |key, index|
247
+ bitmask[index] = "1" if row_assigns.key?(key)
248
+ end
249
+ row.push(bitmask)
250
+ end
185
251
  end
186
252
 
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
253
+ def build_conditions(table, values_table)
254
+ return build_simple_conditions(table) unless values_table
193
255
 
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
256
+ read_keys.map.with_index do |key, index|
257
+ table[key].eq(values_table[index])
204
258
  end
205
259
  end
206
260
 
207
261
  def build_set_assignments(table, values_table, bitmask_keys)
208
- column = read_keys.count { |k| !constant_conditions.key?(k) }
262
+ set_assignments = if values_table
263
+ build_join_assignments(table, values_table, bitmask_keys)
264
+ else
265
+ build_simple_assignments(table)
266
+ end
267
+
268
+ if timestamp_keys.any?
269
+ # Timestamp assignments precede data assignments to increase the
270
+ # chance MySQL will actually run them against the original data.
271
+ set_assignments = timestamp_assignments(set_assignments) + set_assignments
272
+ end
273
+
274
+ set_assignments
275
+ end
276
+
277
+ def build_join_assignments(table, values_table, bitmask_keys)
278
+ column = read_keys.size
209
279
 
210
280
  bitmask_functions = bitmask_keys.index_with.with_index(1) do |key, index|
211
281
  Arel::Nodes::NamedFunction.new("SUBSTRING", [values_table[-1], index, 1])
212
282
  end
213
283
 
214
- set_assignments = write_keys.map do |key|
284
+ write_keys.map do |key|
215
285
  formula = @formulas[key]
216
286
  lhs = table[key]
217
287
 
218
288
  if constant_assigns.key?(key)
219
- rhs = Arel::Nodes::Casted.new(constant_assigns[key], table[key])
289
+ rhs = Arel::Nodes::Quoted.new(constant_assigns[key])
220
290
  else
221
291
  rhs = values_table[column]
222
292
  column += 1
@@ -224,64 +294,28 @@ module ActiveRecord::UpdateInBulk
224
294
  end
225
295
 
226
296
  if function = bitmask_functions[key]
227
- rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(table[key])
297
+ rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(lhs)
228
298
  elsif optional_keys.include?(key)
229
- rhs = table.coalesce(rhs, table[key])
299
+ rhs = table.coalesce(rhs, lhs)
230
300
  end
231
- [table[key], rhs]
232
- 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
301
+ [lhs, rhs]
238
302
  end
239
-
240
- set_assignments
241
303
  end
242
304
 
243
305
  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) }
306
+ variable_keys = read_keys + write_keys.reject { |k| constant_assigns.key?(k) }
245
307
  columns_hash = model.columns_hash
246
308
  model_types = variable_keys.map! { |key| columns_hash.fetch(key) }
247
309
  connection.typecast_values_table(values_table, model_types).alias(self.class.values_table_name)
248
310
  end
249
311
 
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)
312
+ def timestamp_assignments(set_assignments)
313
+ if always_record_timestamps?
314
+ return timestamp_keys.map do |key|
315
+ [model.arel_table[key], connection.high_precision_current_timestamp]
279
316
  end
280
- row.push(bitmask)
281
317
  end
282
- end
283
318
 
284
- def timestamp_assignments(set_assignments)
285
319
  case_conditions = set_assignments.map do |left, right|
286
320
  left.is_not_distinct_from(right)
287
321
  end
@@ -294,23 +328,31 @@ module ActiveRecord::UpdateInBulk
294
328
  end
295
329
  end
296
330
 
297
- # When you assign a value to NULL, we need to use a bitmask to distinguish
331
+ def record_timestamps_enabled?
332
+ !@record_timestamps.nil? && @record_timestamps != false
333
+ end
334
+
335
+ def always_record_timestamps?
336
+ @record_timestamps == :always
337
+ end
338
+
339
+ # When you assign a value to NULL, we need to use a bitmask to distinguish that
298
340
  # row in the values table from rows where the column is not to be assigned at all.
299
341
  def might_be_nil_value?(value)
300
- value.nil? || opaque_value?(value)
342
+ value.nil? || Arel.arel_node?(value)
301
343
  end
302
344
 
303
- def opaque_value?(value)
304
- value.is_a?(Arel::Nodes::SqlLiteral) || value.is_a?(Arel::Nodes::BindParam)
345
+ def quoted_value(value)
346
+ Arel.arel_node?(value) ? value : Arel::Nodes::Quoted.new(value)
305
347
  end
306
348
 
307
349
  def normalize_formulas(formulas)
308
350
  return {} if formulas.blank?
309
351
 
310
352
  normalized = formulas.to_h do |key, value|
311
- [key.to_s, value.is_a?(Proc) ? value : value.to_s]
353
+ [key.to_s, value.is_a?(Proc) ? value : value.to_sym]
312
354
  end
313
- invalid = normalized.values.reject { |v| v.is_a?(Proc) } - FORMULAS
355
+ invalid = normalized.values.reject { |v| v.is_a?(Proc) || self.class.registered_formula?(v) }
314
356
  if invalid.any?
315
357
  raise ArgumentError, "Unknown formula: #{invalid.first.inspect}"
316
358
  end
@@ -329,7 +371,7 @@ module ActiveRecord::UpdateInBulk
329
371
 
330
372
  def resolve_read_and_write_keys!
331
373
  @read_keys = @conditions.first.keys.to_set
332
- @write_keys = @assigns.flat_map(&:keys).to_set
374
+ @write_keys = @assigns.reduce(Set.new) { |set, row| set.merge(row.keys) }
333
375
  end
334
376
 
335
377
  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,15 +4,49 @@ 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
- initializer "active_record_update_in_bulk.values_table_alias", after: :load_config_initializers do |app|
11
- if (bulk_alias = app.config.active_record_update_in_bulk.values_table_alias)
12
- unless bulk_alias.instance_of?(String) && !bulk_alias.empty?
13
- raise ArgumentError, "values_table_alias must be a non-empty String"
32
+ initializer "active_record_update_in_bulk.values_table_alias" do |app|
33
+ ActiveSupport.on_load(:active_record) do
34
+ if (bulk_alias = app.config.active_record_update_in_bulk.values_table_alias)
35
+ unless bulk_alias.instance_of?(String) && !bulk_alias.empty?
36
+ raise ArgumentError, "values_table_alias must be a non-empty String"
37
+ end
38
+ ActiveRecord::UpdateInBulk::Builder.values_table_name = bulk_alias
14
39
  end
15
- ActiveRecord::UpdateInBulk::Builder.values_table_name = bulk_alias
40
+ end
41
+ end
42
+
43
+ initializer "active_record_update_in_bulk.ignore_scope_order" do |app|
44
+ ActiveSupport.on_load(:active_record) do
45
+ ignore_scope_order = app.config.active_record_update_in_bulk.ignore_scope_order
46
+ unless ignore_scope_order == true || ignore_scope_order == false
47
+ raise ArgumentError, "ignore_scope_order must be true or false"
48
+ end
49
+ ActiveRecord::UpdateInBulk::Builder.ignore_scope_order = ignore_scope_order
16
50
  end
17
51
  end
18
52
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Carvalho
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.0'
26
- - !ruby/object:Gem::Dependency
27
- name: rake
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
26
  description: Introduces update_in_bulk(), a method to update many records in a table
41
27
  with different values in a single SQL statement.
42
28
  email:
@@ -52,12 +38,8 @@ files:
52
38
  - lib/activerecord-updateinbulk/adapters/abstract_mysql_adapter.rb
53
39
  - lib/activerecord-updateinbulk/adapters/postgresql_adapter.rb
54
40
  - 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
41
  - lib/activerecord-updateinbulk/arel/nodes/values_table.rb
59
42
  - lib/activerecord-updateinbulk/arel/select_manager.rb
60
- - lib/activerecord-updateinbulk/arel/visitors/sqlite.rb
61
43
  - lib/activerecord-updateinbulk/arel/visitors/to_sql.rb
62
44
  - lib/activerecord-updateinbulk/base.rb
63
45
  - 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)