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 +4 -4
- data/README.md +26 -10
- data/lib/activerecord-updateinbulk/adapters/abstract_adapter.rb +8 -0
- data/lib/activerecord-updateinbulk/arel/select_manager.rb +2 -2
- data/lib/activerecord-updateinbulk/arel/visitors/to_sql.rb +1 -13
- data/lib/activerecord-updateinbulk/base.rb +4 -8
- data/lib/activerecord-updateinbulk/builder.rb +156 -64
- data/lib/activerecord-updateinbulk/querying.rb +2 -2
- data/lib/activerecord-updateinbulk/railtie.rb +30 -0
- data/lib/activerecord-updateinbulk/relation.rb +20 -8
- data/lib/activerecord-updateinbulk/version.rb +1 -1
- metadata +1 -5
- data/lib/activerecord-updateinbulk/arel/math.rb +0 -17
- data/lib/activerecord-updateinbulk/arel/nodes/greatest.rb +0 -6
- data/lib/activerecord-updateinbulk/arel/nodes/least.rb +0 -6
- data/lib/activerecord-updateinbulk/arel/visitors/sqlite.rb +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25ba0ab087a02a9c275429d50daa9b31bc2bc93fe4ae40c726402f4fd2f21ec2
|
|
4
|
+
data.tar.gz: d32540835f40aaaf11aa0180d9f49763c775ff7b55287dec5cd9db568b20f45f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 :
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
119
|
+
Inventory.update_in_bulk({
|
|
106
120
|
"Christmas balls" => { quantity: 300 },
|
|
107
121
|
"Christmas tree" => { quantity: 10 }
|
|
108
|
-
}, formulas: { quantity:
|
|
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
|
-
|
|
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
|
|
@@ -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 =
|
|
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
|
|
53
|
+
when :add
|
|
51
54
|
lhs + rhs
|
|
52
|
-
when
|
|
55
|
+
when :subtract
|
|
53
56
|
lhs - rhs
|
|
54
|
-
when
|
|
57
|
+
when :concat_append
|
|
55
58
|
lhs.concat(rhs)
|
|
56
|
-
when
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
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 ||=
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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]))
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
connection.
|
|
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
|
|
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.
|
|
215
|
+
rows = @conditions.map.with_index do |row_conditions, row_index|
|
|
203
216
|
row_assigns = @assigns[row_index]
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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? ||
|
|
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.
|
|
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.
|
|
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!
|
|
@@ -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>,
|
|
60
|
-
# <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
|
-
#
|
|
72
|
-
# Inventory.update_in_bulk(updates, formulas: { quantity:
|
|
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? &&
|
|
91
|
-
raise NotImplementedError, "No support to update
|
|
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,
|
|
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
|
-
|
|
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] }
|
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.
|
|
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,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)
|