activerecord-updateinbulk 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +165 -0
- data/lib/activerecord-updateinbulk/adapters/abstract_adapter.rb +45 -0
- data/lib/activerecord-updateinbulk/adapters/abstract_mysql_adapter.rb +30 -0
- data/lib/activerecord-updateinbulk/adapters/postgresql_adapter.rb +42 -0
- data/lib/activerecord-updateinbulk/adapters/sqlite3_adapter.rb +10 -0
- data/lib/activerecord-updateinbulk/arel/math.rb +17 -0
- data/lib/activerecord-updateinbulk/arel/nodes/greatest.rb +6 -0
- data/lib/activerecord-updateinbulk/arel/nodes/least.rb +6 -0
- data/lib/activerecord-updateinbulk/arel/nodes/values_table.rb +49 -0
- data/lib/activerecord-updateinbulk/arel/select_manager.rb +11 -0
- data/lib/activerecord-updateinbulk/arel/visitors/sqlite.rb +20 -0
- data/lib/activerecord-updateinbulk/arel/visitors/to_sql.rb +66 -0
- data/lib/activerecord-updateinbulk/base.rb +47 -0
- data/lib/activerecord-updateinbulk/builder.rb +312 -0
- data/lib/activerecord-updateinbulk/querying.rb +11 -0
- data/lib/activerecord-updateinbulk/railtie.rb +20 -0
- data/lib/activerecord-updateinbulk/relation.rb +127 -0
- data/lib/activerecord-updateinbulk/version.rb +7 -0
- data/lib/activerecord-updateinbulk.rb +9 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4ecff8b9c04987407c4d1d3e1bc0dd14e3bb0f54ff53cffd09cbdf76ba0afdc3
|
|
4
|
+
data.tar.gz: 2f6031daf51bbac0fb3455ffc02bba6d2590dff17c95665cfc4fe31994487ea7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 16eda867b24725afda253569d0474db0c02658dd59e6a31623d2279dfdbad26bbedf3717d3a72240bf59b7de03e5745d7cd0c8c54ab6e992b8bcfdbad059b840
|
|
7
|
+
data.tar.gz: a960ee63d7ec9ba055b61a636a7acb735c0aca31ab9f2f114f28ea45045f142a1bc266ac9287371253c57df8145a68617937956382a24f287c1ef65da2724c47
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruno Carvalho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# ActiveRecord Update in Bulk
|
|
2
|
+
|
|
3
|
+
Introduces ``Relation#update_in_bulk``, a method to update many records in a table with different values in a single SQL statement,
|
|
4
|
+
something traditionally performed with either $N$ consecutive updates or a series of repetitive `CASE` statements.
|
|
5
|
+
|
|
6
|
+
The method generates a single `UPDATE` query with an inner join to a handcrafted `VALUES` table constructor holding both row matching _conditions_ and the values to assign to each set of rows matched.
|
|
7
|
+
This construct is available on the latest versions of all databases supported by rails.
|
|
8
|
+
|
|
9
|
+
Similar to `update_all`, it returns the number of affected rows, and bumps update timestamps by default.
|
|
10
|
+
|
|
11
|
+
Tested on Ruby 3.4 and Rails 8 for all builtin databases on latest versions.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Indexed format: hash of primary key => attributes to update.
|
|
17
|
+
Employee.update_in_bulk({
|
|
18
|
+
1 => { salary: 75_000, title: "Software engineer" },
|
|
19
|
+
2 => { title: "Claude prompter" },
|
|
20
|
+
3 => { salary: 68_000 }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# Composite primary keys work as well.
|
|
24
|
+
FlightSeat.update_in_bulk({
|
|
25
|
+
["AA100", "12A"] => { passenger: "Alice" },
|
|
26
|
+
["AA100", "12B"] => { passenger: "Bob" }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
# Paired format: array of [conditions, assigns] pairs.
|
|
30
|
+
# Conditions don't have to be primary keys, they can refer to any columns in the target table.
|
|
31
|
+
Employee.update_in_bulk([
|
|
32
|
+
[{ department: "Sales" }, { bonus: 2500 }],
|
|
33
|
+
[{ department: "Engineering" }, { bonus: 500 }]
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
# Separated format: parallel arrays of conditions and assigns.
|
|
37
|
+
# Primary key conditions can be given in their natural form.
|
|
38
|
+
Employee.update_in_bulk(
|
|
39
|
+
[1, 2, { id: 3 }],
|
|
40
|
+
[{ salary: 75_000, title: "Software engineer" }, { title: "Claude prompter" }, { salary: 68_000 }]
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Relation scoping
|
|
45
|
+
|
|
46
|
+
Relation constraints are preserved, including joins:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Only adjust salaries for currently active employees.
|
|
50
|
+
Employee.where(active: true).update_in_bulk([
|
|
51
|
+
[{ department: "Sales" }, { bonus: 2500 }],
|
|
52
|
+
[{ department: "Engineering" }, { bonus: 500 }]
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
# Joins work too - update orders that have at least one shipped item.
|
|
56
|
+
Order.joins(:items).where(items: { status: :shipped }).update_in_bulk({
|
|
57
|
+
10 => { status: :fulfilled },
|
|
58
|
+
11 => { status: :fulfilled }
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Record timestamps
|
|
63
|
+
|
|
64
|
+
By default `update_in_bulk` implicitly bumps update timestamps similar to `upsert_all`.
|
|
65
|
+
- If the model has `updated_at`/`updated_on`, these are bumped *iff the row actually changed*.
|
|
66
|
+
- Passing `record_timestamps: false` can disable bumping the update timestamps for the query.
|
|
67
|
+
- The `updated_at` columns can also be manually assigned, this disables the implicit bump behaviour.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Employee.update_in_bulk({
|
|
71
|
+
1 => { department: "Engineering" },
|
|
72
|
+
2 => { department: "Sales" }
|
|
73
|
+
}, record_timestamps: false)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Formulas (computed assignments)
|
|
77
|
+
|
|
78
|
+
In all examples so far the queries simply assign predetermined values to rows matched, irrespective of their previous values.
|
|
79
|
+
|
|
80
|
+
Formulas can augment this in the predictable way of letting you set a custom expression for the assignment, where the new value can be based on the state of current row, the incoming value(s) for that row, and even values in other tables joined in.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Fulfill an order: subtract different quantities from each product stock in one statement.
|
|
84
|
+
Inventory.update_in_bulk({
|
|
85
|
+
"Christmas balls" => { quantity: 73 },
|
|
86
|
+
"Christmas tree" => { quantity: 1 }
|
|
87
|
+
}, formulas: { quantity: :subtract })
|
|
88
|
+
# Generates: inventories.quantity = inventories.quantity - t.column2
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Built-in formulas:
|
|
92
|
+
- `:add :subtract :min :max :concat_append :concat_prepend`
|
|
93
|
+
|
|
94
|
+
Custom formulas are supported by providing a `Proc`. The proc takes `(lhs,rhs,model)` and must return an **Arel node**.
|
|
95
|
+
Here `lhs` and `rhs` are instances of `Arel::Attribute` corresponding to the target table and values table respectively.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Restock some products, but cap inventory at some maximum amount.
|
|
99
|
+
# LEAST(metadata.max_stock, inventories.quantity + t.quantity)
|
|
100
|
+
add_capped = proc |lhs, rhs| do
|
|
101
|
+
Arel::Nodes::Least.new([Arel::Attribute.new("metadata", "max_stock"), lhs + rhs])
|
|
102
|
+
end
|
|
103
|
+
Inventory.joins(:metadata).update_in_bulk({
|
|
104
|
+
"Christmas balls" => { quantity: 300 },
|
|
105
|
+
"Christmas tree" => { quantity: 10 }
|
|
106
|
+
}, formulas: { quantity: add_capped })
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Notes
|
|
110
|
+
|
|
111
|
+
Running `EXPLAIN` on the database engines indicates they do run these queries as one would expect, using the correct index based on the join condition, but there are no tests or benchmarks for this yet.
|
|
112
|
+
|
|
113
|
+
Given the nature of the query being an inner join with the condition columns, all conditions must use the same keys, and they should not have _NULL_ values, which won't match any rows.
|
|
114
|
+
|
|
115
|
+
Conditions and assigns must reference actual columns on the target table. Virtual columns for use with formulas are not implemented (requires explicit casting interface to be usable in postgres).
|
|
116
|
+
|
|
117
|
+
The `UPDATE` is single-shot in any compliant database:
|
|
118
|
+
- Either all rows matched are updated or none are.
|
|
119
|
+
- Errors may occur for any of the usual reasons: a calculation error, or a check/unique constraint violation.
|
|
120
|
+
- This can be used to design a query that updates zero rows if it fails to update any of them, something which usually requires a transaction.
|
|
121
|
+
- Rows earlier in the statement do not affect later rows - the row updates are not 'sequenced'.
|
|
122
|
+
|
|
123
|
+
## Limitations
|
|
124
|
+
|
|
125
|
+
There is no support for `ORDER BY`, `LIMIT`, `OFFSET`, `GROUP` or `HAVING` clauses in the relation.
|
|
126
|
+
|
|
127
|
+
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).
|
|
128
|
+
|
|
129
|
+
## Examples
|
|
130
|
+
|
|
131
|
+
The query's skeleton looks like this:
|
|
132
|
+
```sql
|
|
133
|
+
--- postgres
|
|
134
|
+
UPDATE "books" SET "name" = "t"."column2"
|
|
135
|
+
FROM "books" JOIN (VALUES (1, 'C++'), (2, 'Web'), ...) "t" ON "books"."id" = "t"."column1"
|
|
136
|
+
WHERE ...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Example use cases:
|
|
140
|
+
- Offset `position` in a set of many ordered records after an element is added or removed from the middle of the list.
|
|
141
|
+
- Decrement (or increment) `stock` or `balance` simultaneously in multiple rows by different amounts, noop-ing if any value would go outside permissible bounds. => add/subtract formula with database types or check constraints
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
## Testing & Development
|
|
145
|
+
|
|
146
|
+
It is important to test both MariaDB and MySQL: their values table semantics differ significantly.
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# setup local test databases
|
|
150
|
+
bundle exec rake db:prepare:postgresql
|
|
151
|
+
bundle exec rake db:prepare:mysql2
|
|
152
|
+
|
|
153
|
+
# run on local databases, force rebuilds schemas
|
|
154
|
+
bundle exec rake test:sqlite3
|
|
155
|
+
bundle exec rake test:postgresql
|
|
156
|
+
bundle exec rake test:mysql2
|
|
157
|
+
|
|
158
|
+
# run on docker-compose databases
|
|
159
|
+
bin/test-docker sqlite3
|
|
160
|
+
bin/test-docker postgresql
|
|
161
|
+
bin/test-docker mysql2
|
|
162
|
+
bin/test-docker mariadb
|
|
163
|
+
|
|
164
|
+
bundle exec rubocop -a
|
|
165
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord::UpdateInBulk
|
|
4
|
+
module AbstractAdapter
|
|
5
|
+
# Whether the database supports the SQL VALUES table constructor.
|
|
6
|
+
def supports_values_tables?
|
|
7
|
+
true
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# A string prepended to each row literal in the VALUES table constructor.
|
|
11
|
+
# Empty by default, per the standard. MySQL overrides this to <tt>"ROW"</tt>
|
|
12
|
+
# to produce <tt>VALUES ROW(1, 2), ROW(3, 4)</tt>.
|
|
13
|
+
def values_table_row_prefix
|
|
14
|
+
""
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Whether VALUES table serialization must always include explicit column
|
|
18
|
+
# aliases (because defaults are missing or not statically known).
|
|
19
|
+
def values_table_requires_aliasing?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns an array of +width+ column names used by the database for a
|
|
24
|
+
# VALUES table constructor of the given width. These are the native names
|
|
25
|
+
# assigned to each column position when values_table_requires_aliasing?
|
|
26
|
+
# is false; otherwise they are alias conventions.
|
|
27
|
+
def values_table_default_column_names(width)
|
|
28
|
+
(1..width).map { |i| "column#{i}" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Hook for adapters that add explicit type casts to VALUES table entries
|
|
32
|
+
# so column types are correctly inferred by the database.
|
|
33
|
+
#
|
|
34
|
+
# Receives the +values_table+ (<tt>Arel::Nodes::ValuesTable</tt>) and
|
|
35
|
+
# +columns+ (an array of <tt>ActiveRecord::ConnectionAdapters::Column</tt>).
|
|
36
|
+
#
|
|
37
|
+
# Returns the typecasted Arel node: a new node or +values_table+ itself,
|
|
38
|
+
# possibly modified in place.
|
|
39
|
+
def typecast_values_table(values_table, _columns)
|
|
40
|
+
values_table
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ActiveRecord::ConnectionAdapters::AbstractAdapter.include(ActiveRecord::UpdateInBulk::AbstractAdapter)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/abstract_mysql_adapter"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord::UpdateInBulk
|
|
6
|
+
module AbstractMysqlAdapter
|
|
7
|
+
def supports_values_tables?
|
|
8
|
+
mariadb? ? database_version >= "10.3.3" : database_version >= "8.0.19"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def values_table_row_prefix
|
|
12
|
+
mariadb? ? "" : "ROW"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def values_table_default_column_names(width)
|
|
16
|
+
if mariadb?
|
|
17
|
+
(1..width).map { |i| "column#{i}" } # convention
|
|
18
|
+
else
|
|
19
|
+
(0...width).map { |i| "column_#{i}" }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# MariaDB always requires aliasing since there are no fixed column names
|
|
24
|
+
def values_table_requires_aliasing?
|
|
25
|
+
mariadb?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.include(ActiveRecord::UpdateInBulk::AbstractMysqlAdapter)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/postgresql_adapter"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord::UpdateInBulk
|
|
6
|
+
module PostgreSQLAdapter
|
|
7
|
+
SAFE_TYPES_FOR_VALUES_TABLE = [:integer, :string, :text, :boolean].freeze
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def typecast_values_table(values_table, columns)
|
|
11
|
+
types = columns.map.with_index do |column, index|
|
|
12
|
+
case column
|
|
13
|
+
when ActiveRecord::ConnectionAdapters::PostgreSQL::Column
|
|
14
|
+
if SAFE_TYPES_FOR_VALUES_TABLE.exclude?(column.type) ||
|
|
15
|
+
values_table.rows.all? { |row| row[index].nil? }
|
|
16
|
+
column.sql_type
|
|
17
|
+
end
|
|
18
|
+
when Arel::Nodes::SqlLiteral, nil
|
|
19
|
+
column
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "Unexpected column type: #{column.class.name}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
return values_table if types.all?(&:nil?)
|
|
26
|
+
|
|
27
|
+
aliases = values_table.columns
|
|
28
|
+
default_columns = values_table_default_column_names(values_table.width)
|
|
29
|
+
values_table = Arel::Nodes::ValuesTable.new(values_table.name, values_table.rows, default_columns)
|
|
30
|
+
|
|
31
|
+
# from("t") is not required in postgres 16+, can be from(nil)
|
|
32
|
+
values_table.from("t").project((0...values_table.width).map do |index|
|
|
33
|
+
proj = Arel::Nodes::UnqualifiedColumn.new(values_table[index])
|
|
34
|
+
proj = proj.cast(proj, Arel.sql(types[index])) if types[index]
|
|
35
|
+
proj = proj.as(Arel::Nodes::UnqualifiedColumn.new(aliases[index])) if aliases[index] != default_columns[index]
|
|
36
|
+
proj
|
|
37
|
+
end)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(ActiveRecord::UpdateInBulk::PostgreSQLAdapter)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/sqlite3_adapter"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord::UpdateInBulk
|
|
6
|
+
module SQLite3Adapter
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
ActiveRecord::ConnectionAdapters::SQLite3Adapter.include(ActiveRecord::UpdateInBulk::SQLite3Adapter)
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arel::Nodes
|
|
4
|
+
# Represents a SQL VALUES table constructor as an Arel node.
|
|
5
|
+
# Mirrors Arel::Table behavior by requiring a name at construction time.
|
|
6
|
+
# Column names are also required because adapter defaults vary; obtain
|
|
7
|
+
# defaults through <tt>connection.values_table_default_column_names(width)</tt>.
|
|
8
|
+
#
|
|
9
|
+
class ValuesTable < Arel::Nodes::Node
|
|
10
|
+
attr_reader :name, :width, :rows, :columns
|
|
11
|
+
alias :table_alias :name
|
|
12
|
+
|
|
13
|
+
# +name+ - The table name (required to mirror Arel::Table).
|
|
14
|
+
# +rows+ - An array of arrays; each inner array is one row of values.
|
|
15
|
+
# +columns+ - An array of column name strings, typically from
|
|
16
|
+
# <tt>connection.values_table_default_column_names(width)</tt>.
|
|
17
|
+
def initialize(name, rows, columns)
|
|
18
|
+
@name = name.to_s
|
|
19
|
+
@width = rows.first.size
|
|
20
|
+
@rows = rows
|
|
21
|
+
@columns = columns.map(&:to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](name, table = self)
|
|
25
|
+
name = columns[name] if name.is_a?(Integer)
|
|
26
|
+
name = name.name if name.is_a?(Symbol)
|
|
27
|
+
Arel::Attribute.new(table, name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from(table = name)
|
|
31
|
+
Arel::SelectManager.new(table ? self.alias(table) : grouping(self))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def alias(table = name)
|
|
35
|
+
Arel::Nodes::TableAlias.new(grouping(self), table)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
delegate :to_cte, to: :alias
|
|
39
|
+
|
|
40
|
+
def hash
|
|
41
|
+
[@name, @rows, @columns].hash
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def eql?(other)
|
|
45
|
+
@name == other.name && @rows == other.rows && @columns == other.columns
|
|
46
|
+
end
|
|
47
|
+
alias :== :eql?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
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)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord::UpdateInBulk
|
|
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
|
+
def visit_Arel_Nodes_ValuesTable(o, collector)
|
|
18
|
+
row_prefix = @connection.values_table_row_prefix
|
|
19
|
+
|
|
20
|
+
if !@connection.values_table_requires_aliasing? && o.columns == @connection.values_table_default_column_names(o.width)
|
|
21
|
+
return build_values_table_constructor(o.rows, collector, row_prefix)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
column_aliases = o.columns
|
|
25
|
+
|
|
26
|
+
# Extract the first row into a handrolled SELECT and put the aliases there.
|
|
27
|
+
collector << "SELECT "
|
|
28
|
+
o.rows[0].each_with_index do |value, i|
|
|
29
|
+
collector << ", " unless i == 0
|
|
30
|
+
collector = build_values_table_single_value(value, collector)
|
|
31
|
+
collector << " " << quote_column_name(column_aliases[i])
|
|
32
|
+
end
|
|
33
|
+
unless o.rows.size == 1
|
|
34
|
+
collector << " UNION ALL "
|
|
35
|
+
collector = build_values_table_constructor(o.rows[1...], collector, row_prefix)
|
|
36
|
+
end
|
|
37
|
+
collector
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
def build_values_table_single_value(value, collector)
|
|
42
|
+
case value
|
|
43
|
+
when Arel::Nodes::SqlLiteral, Arel::Nodes::BindParam, ActiveModel::Attribute
|
|
44
|
+
visit(value, collector)
|
|
45
|
+
else
|
|
46
|
+
collector << quote(value).to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_values_table_constructor(rows, collector, row_prefix = "")
|
|
51
|
+
collector << "VALUES "
|
|
52
|
+
rows.each_with_index do |row, i|
|
|
53
|
+
collector << ", " unless i == 0
|
|
54
|
+
collector << row_prefix << "("
|
|
55
|
+
row.each_with_index do |value, i|
|
|
56
|
+
collector << ", " unless i == 0
|
|
57
|
+
collector = build_values_table_single_value(value, collector)
|
|
58
|
+
end
|
|
59
|
+
collector << ")"
|
|
60
|
+
end
|
|
61
|
+
collector
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Arel::Visitors::ToSql.prepend(ActiveRecord::UpdateInBulk::ToSql)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module UpdateInBulk
|
|
7
|
+
ADAPTER_PATH = "activerecord-updateinbulk/adapters"
|
|
8
|
+
ADAPTER_EXTENSION_MAP = {
|
|
9
|
+
"mysql2" => "abstract_mysql",
|
|
10
|
+
"trilogy" => "abstract_mysql",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
def self.require_adapter(adapter)
|
|
14
|
+
adapter = ADAPTER_EXTENSION_MAP.fetch(adapter, adapter)
|
|
15
|
+
require File.join(ADAPTER_PATH, "#{adapter}_adapter")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.load_from_connection_pool(connection_pool)
|
|
19
|
+
require_adapter connection_pool.db_config.adapter
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
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
|
+
require "activerecord-updateinbulk/arel/nodes/values_table"
|
|
29
|
+
require "activerecord-updateinbulk/arel/visitors/to_sql"
|
|
30
|
+
require "activerecord-updateinbulk/arel/visitors/sqlite"
|
|
31
|
+
require "activerecord-updateinbulk/arel/select_manager"
|
|
32
|
+
require "activerecord-updateinbulk/relation"
|
|
33
|
+
require "activerecord-updateinbulk/querying"
|
|
34
|
+
|
|
35
|
+
require "activerecord-updateinbulk/adapters/abstract_adapter"
|
|
36
|
+
|
|
37
|
+
module ActiveRecord::UpdateInBulk
|
|
38
|
+
module ConnectionHandler
|
|
39
|
+
def establish_connection(*args, **kwargs, &block)
|
|
40
|
+
pool = super(*args, **kwargs, &block)
|
|
41
|
+
ActiveRecord::UpdateInBulk.load_from_connection_pool pool
|
|
42
|
+
pool
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
ActiveRecord::ConnectionAdapters::ConnectionHandler.prepend(ActiveRecord::UpdateInBulk::ConnectionHandler)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/enumerable"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord::UpdateInBulk
|
|
6
|
+
class Builder
|
|
7
|
+
FORMULAS = %w[add subtract concat_append concat_prepend min max].freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_accessor :values_table_name
|
|
11
|
+
|
|
12
|
+
# Normalize all input formats into separated format [conditions, assigns].
|
|
13
|
+
def normalize_updates(model, updates, values = nil)
|
|
14
|
+
conditions = []
|
|
15
|
+
assigns = []
|
|
16
|
+
|
|
17
|
+
if values # separated format
|
|
18
|
+
unless updates.is_a?(Array) && values.is_a?(Array)
|
|
19
|
+
raise ArgumentError, "Separated format expects arrays for conditions and values"
|
|
20
|
+
end
|
|
21
|
+
if updates.size != values.size
|
|
22
|
+
raise ArgumentError, "Conditions and values must have the same length"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
updates.each_with_index do |row_conditions, index|
|
|
26
|
+
row_assigns = values[index]
|
|
27
|
+
next if row_assigns.blank?
|
|
28
|
+
conditions << normalize_conditions(model, row_conditions)
|
|
29
|
+
assigns << row_assigns.stringify_keys
|
|
30
|
+
end
|
|
31
|
+
elsif updates.is_a?(Hash) # indexed format
|
|
32
|
+
updates.each do |id, row_assigns|
|
|
33
|
+
next if row_assigns.blank?
|
|
34
|
+
conditions << normalize_conditions(model, id)
|
|
35
|
+
assigns << row_assigns.stringify_keys
|
|
36
|
+
end
|
|
37
|
+
else # paired format
|
|
38
|
+
updates.each do |(row_conditions, row_assigns)|
|
|
39
|
+
next if row_assigns.blank?
|
|
40
|
+
conditions << normalize_conditions(model, row_conditions)
|
|
41
|
+
assigns << row_assigns.stringify_keys
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
[conditions, assigns]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
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
|
|
68
|
+
else
|
|
69
|
+
rhs
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_proc_formula(formula, lhs, rhs, model)
|
|
74
|
+
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"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
def normalize_conditions(model, conditions)
|
|
86
|
+
if conditions.is_a?(Hash)
|
|
87
|
+
conditions
|
|
88
|
+
elsif model.composite_primary_key?
|
|
89
|
+
primary_key_zip(model.primary_key, conditions)
|
|
90
|
+
else
|
|
91
|
+
{ model.primary_key => primary_key_unwrap(conditions) }
|
|
92
|
+
end.stringify_keys
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def primary_key_zip(keys, values)
|
|
96
|
+
unless values.is_a?(Array)
|
|
97
|
+
raise ArgumentError, "Model has composite primary key, but a condition key given is not an array"
|
|
98
|
+
end
|
|
99
|
+
if keys.size != values.size
|
|
100
|
+
raise ArgumentError, "Model primary key has length #{keys.size}, but condition key given has length #{values.size}"
|
|
101
|
+
end
|
|
102
|
+
keys.zip(values).to_h
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def primary_key_unwrap(value)
|
|
106
|
+
if !value.is_a?(Array)
|
|
107
|
+
value
|
|
108
|
+
elsif value.size == 1
|
|
109
|
+
value.first
|
|
110
|
+
else
|
|
111
|
+
raise ArgumentError, "Expected a single value, but got #{value.inspect}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
self.values_table_name = "t"
|
|
116
|
+
|
|
117
|
+
attr_reader :model, :connection
|
|
118
|
+
|
|
119
|
+
def initialize(relation, connection, conditions, assigns, record_timestamps: nil, formulas: nil)
|
|
120
|
+
@model, @connection = relation.model, connection
|
|
121
|
+
@record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps
|
|
122
|
+
@conditions = conditions
|
|
123
|
+
@assigns = assigns
|
|
124
|
+
@formulas = normalize_formulas(formulas)
|
|
125
|
+
|
|
126
|
+
resolve_attribute_aliases!
|
|
127
|
+
resolve_read_and_write_keys!
|
|
128
|
+
verify_read_and_write_keys!
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_arel
|
|
132
|
+
values_table, bitmask_keys = build_values_table
|
|
133
|
+
join_conditions = build_join_conditions(model.arel_table, values_table)
|
|
134
|
+
set_assignments = build_set_assignments(model.arel_table, values_table, bitmask_keys)
|
|
135
|
+
derived_table = typecast_values_table(values_table)
|
|
136
|
+
|
|
137
|
+
[derived_table, join_conditions, set_assignments]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
attr_reader :read_keys, :write_keys
|
|
142
|
+
|
|
143
|
+
def optional_keys
|
|
144
|
+
@optional_keys ||= write_keys - @assigns.map(&:keys).reduce(write_keys, &:intersection)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def timestamp_keys
|
|
148
|
+
@timestamp_keys ||= @record_timestamps ? model.timestamp_attributes_for_update_in_model.to_set - write_keys : Set.new
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build_values_table
|
|
152
|
+
types = (read_keys | write_keys).index_with { |key| model.type_for_attribute(key) }
|
|
153
|
+
rows, bitmask_keys = serialize_values_rows do |key, value|
|
|
154
|
+
next value if Arel::Nodes::SqlLiteral === value
|
|
155
|
+
ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value))
|
|
156
|
+
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
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_join_conditions(table, values_table)
|
|
163
|
+
read_keys.map.with_index do |key, index|
|
|
164
|
+
table[key].eq(values_table[index])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_set_assignments(table, values_table, bitmask_keys)
|
|
169
|
+
bitmask_functions = bitmask_keys.index_with.with_index(1) do |key, index|
|
|
170
|
+
Arel::Nodes::NamedFunction.new("SUBSTRING", [values_table[-1], index, 1])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
set_assignments = write_keys.map.with_index do |key, index|
|
|
174
|
+
formula = @formulas[key]
|
|
175
|
+
lhs = table[key]
|
|
176
|
+
rhs = values_table[index + read_keys.size]
|
|
177
|
+
rhs = self.class.apply_formula(formula, lhs, rhs, model) if formula
|
|
178
|
+
if function = bitmask_functions[key]
|
|
179
|
+
rhs = Arel::Nodes::Case.new(function).when("1").then(rhs).else(table[key])
|
|
180
|
+
elsif optional_keys.include?(key)
|
|
181
|
+
rhs = table.coalesce(rhs, table[key])
|
|
182
|
+
end
|
|
183
|
+
[table[key], rhs]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if timestamp_keys.any?
|
|
187
|
+
set_assignments += timestamp_assignments(set_assignments)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
set_assignments
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def typecast_values_table(values_table)
|
|
194
|
+
columns_hash = model.columns_hash
|
|
195
|
+
model_types = read_keys.to_a.concat(write_keys.to_a).map! { |key| columns_hash.fetch(key) }
|
|
196
|
+
connection.typecast_values_table(values_table, model_types).alias(self.class.values_table_name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def serialize_values_rows(&)
|
|
200
|
+
bitmask_keys = Set.new
|
|
201
|
+
|
|
202
|
+
rows = @conditions.each_with_index.map do |row_conditions, row_index|
|
|
203
|
+
row_assigns = @assigns[row_index]
|
|
204
|
+
condition_values = read_keys.map do |key|
|
|
205
|
+
yield(key, row_conditions[key])
|
|
206
|
+
end
|
|
207
|
+
write_values = write_keys.map do |key|
|
|
208
|
+
next unless row_assigns.key?(key)
|
|
209
|
+
value = yield(key, row_assigns[key])
|
|
210
|
+
bitmask_keys.add(key) if optional_keys.include?(key) && might_be_nil_value?(value)
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
condition_values.concat(write_values)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
[rows, bitmask_keys]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def append_bitmask_column(rows, bitmask_keys)
|
|
220
|
+
rows.each_with_index do |row, row_index|
|
|
221
|
+
row_assigns = @assigns[row_index]
|
|
222
|
+
bitmask = "0" * bitmask_keys.size
|
|
223
|
+
bitmask_keys.each_with_index do |key, index|
|
|
224
|
+
bitmask[index] = "1" if row_assigns.key?(key)
|
|
225
|
+
end
|
|
226
|
+
row.push(bitmask)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def timestamp_assignments(set_assignments)
|
|
231
|
+
case_conditions = set_assignments.map do |left, right|
|
|
232
|
+
left.is_not_distinct_from(right)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
timestamp_keys.map do |key|
|
|
236
|
+
case_assignment = Arel::Nodes::Case.new.when(Arel::Nodes::And.new(case_conditions))
|
|
237
|
+
.then(model.arel_table[key])
|
|
238
|
+
.else(connection.high_precision_current_timestamp)
|
|
239
|
+
[model.arel_table[key], Arel::Nodes::Grouping.new(case_assignment)]
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# When you assign a value to NULL, we need to use a bitmask to distinguish that
|
|
244
|
+
# row in the values table from rows where the column is not to be assigned at all.
|
|
245
|
+
def might_be_nil_value?(value)
|
|
246
|
+
value.nil? || value.is_a?(Arel::Nodes::SqlLiteral) || value.is_a?(Arel::Nodes::BindParam)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def normalize_formulas(formulas)
|
|
250
|
+
return {} if formulas.blank?
|
|
251
|
+
|
|
252
|
+
normalized = formulas.to_h do |key, value|
|
|
253
|
+
[key.to_s, value.is_a?(Proc) ? value : value.to_s]
|
|
254
|
+
end
|
|
255
|
+
invalid = normalized.values.reject { |v| v.is_a?(Proc) } - FORMULAS
|
|
256
|
+
if invalid.any?
|
|
257
|
+
raise ArgumentError, "Unknown formula: #{invalid.first.inspect}"
|
|
258
|
+
end
|
|
259
|
+
normalized
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def resolve_attribute_aliases!
|
|
263
|
+
return if model.attribute_aliases.empty?
|
|
264
|
+
|
|
265
|
+
@conditions.each_with_index do |row_conditions, index|
|
|
266
|
+
row_assigns = @assigns[index]
|
|
267
|
+
row_conditions.transform_keys! { |attribute| model.attribute_alias(attribute) || attribute }
|
|
268
|
+
row_assigns.transform_keys! { |attribute| model.attribute_alias(attribute) || attribute }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def resolve_read_and_write_keys!
|
|
273
|
+
@read_keys = @conditions.first.keys.to_set
|
|
274
|
+
@write_keys = @assigns.flat_map(&:keys).to_set
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def verify_read_and_write_keys!
|
|
278
|
+
if @conditions.empty?
|
|
279
|
+
raise ArgumentError, "Empty updates object"
|
|
280
|
+
end
|
|
281
|
+
if read_keys.empty?
|
|
282
|
+
raise ArgumentError, "Empty conditions object"
|
|
283
|
+
end
|
|
284
|
+
if write_keys.empty?
|
|
285
|
+
raise ArgumentError, "Empty values object"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
@conditions.each_with_index do |row_conditions, index|
|
|
289
|
+
row_assigns = @assigns[index]
|
|
290
|
+
if row_conditions.each_value.any?(nil)
|
|
291
|
+
raise NotImplementedError, "NULL condition values are not supported"
|
|
292
|
+
end
|
|
293
|
+
if row_assigns.blank?
|
|
294
|
+
raise ArgumentError, "Empty values object"
|
|
295
|
+
end
|
|
296
|
+
if read_keys != row_conditions.keys.to_set
|
|
297
|
+
raise ArgumentError, "All objects being updated must have the same condition keys"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
if @formulas.any?
|
|
301
|
+
unknown_formula_key = (@formulas.keys.to_set - write_keys).first
|
|
302
|
+
if unknown_formula_key
|
|
303
|
+
raise ArgumentError, "Formula given for unknown column: #{unknown_formula_key}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
columns = read_keys | write_keys
|
|
308
|
+
unknown_column = (columns - model.columns_hash.keys).first
|
|
309
|
+
raise ActiveRecord::UnknownAttributeError.new(model.new, unknown_column) if unknown_column
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module UpdateInBulk
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
config.active_record_update_in_bulk = ActiveSupport::OrderedOptions.new
|
|
9
|
+
|
|
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"
|
|
14
|
+
end
|
|
15
|
+
ActiveRecord::UpdateInBulk::Builder.values_table_name = bulk_alias
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord::UpdateInBulk
|
|
4
|
+
module Relation
|
|
5
|
+
# Updates multiple groups of records in the current relation using a single
|
|
6
|
+
# SQL UPDATE statement. This does not instantiate models and does not
|
|
7
|
+
# trigger Active Record callbacks or validations. However, values passed
|
|
8
|
+
# through still use Active Record's normal type casting and serialization.
|
|
9
|
+
# Returns the number of rows affected.
|
|
10
|
+
#
|
|
11
|
+
# Three equivalent input formats are supported:
|
|
12
|
+
#
|
|
13
|
+
# *Indexed format* — a hash mapping primary keys to attribute updates:
|
|
14
|
+
#
|
|
15
|
+
# Book.update_in_bulk({
|
|
16
|
+
# 1 => { title: "Agile", price: 10.0 },
|
|
17
|
+
# 2 => { title: "Rails" }
|
|
18
|
+
# })
|
|
19
|
+
#
|
|
20
|
+
# Composite primary keys are supported:
|
|
21
|
+
#
|
|
22
|
+
# FlightSeat.update_in_bulk({
|
|
23
|
+
# ["AA100", "12A"] => { passenger: "Alice" },
|
|
24
|
+
# ["AA100", "12B"] => { passenger: "Bob" }
|
|
25
|
+
# })
|
|
26
|
+
#
|
|
27
|
+
# *Paired format* — an array of <tt>[conditions, assigns]</tt> pairs.
|
|
28
|
+
# Conditions do not need to be primary keys; they may reference any columns in
|
|
29
|
+
# the target table. All pairs must specify the same set of condition
|
|
30
|
+
# columns:
|
|
31
|
+
#
|
|
32
|
+
# Employee.update_in_bulk([
|
|
33
|
+
# [{ department: "Sales" }, { bonus: 2500 }],
|
|
34
|
+
# [{ department: "Engineering" }, { bonus: 500 }]
|
|
35
|
+
# ])
|
|
36
|
+
#
|
|
37
|
+
# *Separated format* — parallel arrays of conditions and assigns:
|
|
38
|
+
#
|
|
39
|
+
# Employee.update_in_bulk(
|
|
40
|
+
# [1, 2, { id: 3 }],
|
|
41
|
+
# [{ salary: 75_000 }, { salary: 80_000 }, { salary: 68_000 }]
|
|
42
|
+
# )
|
|
43
|
+
#
|
|
44
|
+
# ==== Options
|
|
45
|
+
#
|
|
46
|
+
# [:record_timestamps]
|
|
47
|
+
# By default, automatic setting of timestamp columns is controlled by
|
|
48
|
+
# the model's <tt>record_timestamps</tt> config, matching typical
|
|
49
|
+
# behavior. Timestamps are only bumped when the row actually changes.
|
|
50
|
+
#
|
|
51
|
+
# To override this and force automatic setting of timestamp columns one
|
|
52
|
+
# way or the other, pass <tt>:record_timestamps</tt>.
|
|
53
|
+
#
|
|
54
|
+
# [:formulas]
|
|
55
|
+
# A hash of column names to formula identifiers or Procs. Instead of
|
|
56
|
+
# a simple assignment, the column is set to an expression that can
|
|
57
|
+
# reference both the current selected row value and the incoming value.
|
|
58
|
+
#
|
|
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>.
|
|
61
|
+
#
|
|
62
|
+
# Inventory.update_in_bulk({
|
|
63
|
+
# "Christmas balls" => { quantity: 73 },
|
|
64
|
+
# "Christmas tree" => { quantity: 1 }
|
|
65
|
+
# }, formulas: { quantity: :subtract })
|
|
66
|
+
#
|
|
67
|
+
# Custom formulas are supported via a Proc that takes
|
|
68
|
+
# <tt>(lhs, rhs)</tt> or <tt>(lhs, rhs, model)</tt> and returns an
|
|
69
|
+
# Arel node:
|
|
70
|
+
#
|
|
71
|
+
# add_capped = ->(lhs, rhs) { lhs.least(lhs + rhs) }
|
|
72
|
+
# Inventory.update_in_bulk(updates, formulas: { quantity: add_capped })
|
|
73
|
+
#
|
|
74
|
+
# ==== Examples
|
|
75
|
+
#
|
|
76
|
+
# # Migration to combine two columns into one for all entries in a table.
|
|
77
|
+
# Book.update_in_bulk([
|
|
78
|
+
# [{ written: false, published: false }, { status: :proposed }],
|
|
79
|
+
# [{ written: true, published: false }, { status: :written }],
|
|
80
|
+
# [{ written: true, published: true }, { status: :published }]
|
|
81
|
+
# ], record_timestamps: false)
|
|
82
|
+
#
|
|
83
|
+
# # Relation scoping is preserved.
|
|
84
|
+
# Employee.where(active: true).update_in_bulk({
|
|
85
|
+
# 1 => { department: "Engineering" },
|
|
86
|
+
# 2 => { department: "Sales" }
|
|
87
|
+
# })
|
|
88
|
+
#
|
|
89
|
+
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)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
conditions, assigns = Builder.normalize_updates(model, updates, values)
|
|
95
|
+
return 0 if @none || conditions.empty?
|
|
96
|
+
|
|
97
|
+
model.with_connection do |c|
|
|
98
|
+
unless c.supports_values_tables?
|
|
99
|
+
raise ArgumentError, "#{c.class} does not support VALUES table constructors"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
arel = eager_loading? ? apply_join_dependency.arel : arel()
|
|
103
|
+
arel.source.left = table
|
|
104
|
+
|
|
105
|
+
values_table, join_conditions, set_assignments = Builder.new(
|
|
106
|
+
self,
|
|
107
|
+
c,
|
|
108
|
+
conditions,
|
|
109
|
+
assigns,
|
|
110
|
+
record_timestamps:,
|
|
111
|
+
formulas:
|
|
112
|
+
).build_arel
|
|
113
|
+
arel = arel.join(values_table).on(*join_conditions)
|
|
114
|
+
|
|
115
|
+
key = if model.composite_primary_key?
|
|
116
|
+
primary_key.map { |pk| table[pk] }
|
|
117
|
+
else
|
|
118
|
+
table[primary_key]
|
|
119
|
+
end
|
|
120
|
+
stmt = arel.compile_update(set_assignments, key)
|
|
121
|
+
c.update(stmt, "#{model} Update in Bulk").tap { reset }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ActiveRecord::Relation.prepend(ActiveRecord::UpdateInBulk::Relation)
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-updateinbulk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Bruno Carvalho
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
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
|
+
description: Introduces update_in_bulk(), a method to update many records in a table
|
|
41
|
+
with different values in a single SQL statement.
|
|
42
|
+
email:
|
|
43
|
+
- bruno.carvalho.feup@gmail.com
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- LICENSE
|
|
49
|
+
- README.md
|
|
50
|
+
- lib/activerecord-updateinbulk.rb
|
|
51
|
+
- lib/activerecord-updateinbulk/adapters/abstract_adapter.rb
|
|
52
|
+
- lib/activerecord-updateinbulk/adapters/abstract_mysql_adapter.rb
|
|
53
|
+
- lib/activerecord-updateinbulk/adapters/postgresql_adapter.rb
|
|
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
|
+
- lib/activerecord-updateinbulk/arel/nodes/values_table.rb
|
|
59
|
+
- lib/activerecord-updateinbulk/arel/select_manager.rb
|
|
60
|
+
- lib/activerecord-updateinbulk/arel/visitors/sqlite.rb
|
|
61
|
+
- lib/activerecord-updateinbulk/arel/visitors/to_sql.rb
|
|
62
|
+
- lib/activerecord-updateinbulk/base.rb
|
|
63
|
+
- lib/activerecord-updateinbulk/builder.rb
|
|
64
|
+
- lib/activerecord-updateinbulk/querying.rb
|
|
65
|
+
- lib/activerecord-updateinbulk/railtie.rb
|
|
66
|
+
- lib/activerecord-updateinbulk/relation.rb
|
|
67
|
+
- lib/activerecord-updateinbulk/version.rb
|
|
68
|
+
homepage: https://github.com/bruno/activerecord-updateinbulk
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata:
|
|
72
|
+
bug_tracker_uri: https://github.com/bruno/activerecord-updateinbulk/issues
|
|
73
|
+
homepage_uri: https://github.com/bruno/activerecord-updateinbulk
|
|
74
|
+
source_code_uri: https://github.com/bruno/activerecord-updateinbulk
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.2'
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 3.6.9
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Bulk update extension for ActiveRecord
|
|
92
|
+
test_files: []
|