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 +4 -4
- data/README.md +24 -8
- data/lib/activerecord-updateinbulk/adapters/abstract_adapter.rb +8 -0
- data/lib/activerecord-updateinbulk/adapters/postgresql_adapter.rb +2 -1
- 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 +16 -8
- data/lib/activerecord-updateinbulk/builder.rb +168 -126
- data/lib/activerecord-updateinbulk/querying.rb +2 -2
- data/lib/activerecord-updateinbulk/railtie.rb +39 -5
- data/lib/activerecord-updateinbulk/relation.rb +20 -8
- data/lib/activerecord-updateinbulk/version.rb +1 -1
- metadata +1 -19
- 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: 1a844813b98d092d304542eb4f8c3ffc6b99a50f7510d430bef229e47c969415
|
|
4
|
+
data.tar.gz: e83aa71485ad9255b977b96afe9a8ec55820eda485d489d16e30629c1a79a971
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 :
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
156
|
+
[derived_table, conditions, set_assignments]
|
|
140
157
|
end
|
|
141
158
|
|
|
142
159
|
private
|
|
143
|
-
attr_reader :read_keys, :write_keys, :
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
@optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
|
|
239
|
+
[rows, bitmask_keys]
|
|
181
240
|
end
|
|
182
241
|
|
|
183
|
-
def
|
|
184
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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(
|
|
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,
|
|
299
|
+
rhs = table.coalesce(rhs, lhs)
|
|
230
300
|
end
|
|
231
|
-
[
|
|
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
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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? ||
|
|
342
|
+
value.nil? || Arel.arel_node?(value)
|
|
301
343
|
end
|
|
302
344
|
|
|
303
|
-
def
|
|
304
|
-
|
|
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.
|
|
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)
|
|
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.
|
|
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!
|
|
@@ -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"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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>,
|
|
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.1
|
|
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,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)
|