activerecord-returning 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/CHANGELOG.md +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +357 -0
- data/lib/activerecord/returning/errors.rb +12 -0
- data/lib/activerecord/returning/querying.rb +18 -0
- data/lib/activerecord/returning/relation_methods.rb +33 -0
- data/lib/activerecord/returning/statement.rb +224 -0
- data/lib/activerecord/returning/version.rb +7 -0
- data/lib/activerecord/returning.rb +65 -0
- data/lib/activerecord-returning.rb +3 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fc65ae5a23d01e74129297d8c261a737b12a7d0704c3603db5b80c071cc1bb5f
|
|
4
|
+
data.tar.gz: 4bad36a8d5323405454f44df90390e08d054123f4c8b852cb142a69bfa98b0e5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 00bd98c430785df957ee8442c02adfa06aa1db3fe53ff7923605b6758d18c76a580f09b143dc9d840bb0dc8d58486d125c0f1d4416634d7352f05978b93b0f64
|
|
7
|
+
data.tar.gz: f7d0fbfe27e2eb1bff15b72f68442d260fea48792ca66ebf837dbb5839b1f2b6acd72fb55ff783ed8d3708d0cf34ca97d3e20fe9ad9d669b2a9f4eff68a217af
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A relation with `from` no longer changes every row in the table. The primary key subquery selects
|
|
12
|
+
`table.id` while `from` renames the table it reads, so the database read `table.id` as a reference to the
|
|
13
|
+
row being changed and matched all of them — a whole-table `UPDATE`/`DELETE` from a relation that selected
|
|
14
|
+
a few rows. It raises `ActiveRecord::Returning::Error` now, pointing at `unscope(:from)`.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- A combination test suite: every relation shape — and combinations of them — run through both
|
|
19
|
+
`update_all_returning` and `delete_all_returning`, asserting the rows the relation selects are the rows
|
|
20
|
+
returned, the rows returned are the rows the database changed, and nothing else moved.
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
|
|
24
|
+
- Documented that `delete_all_returning` on a `has_many` deletes rows, while `delete_all` on the same
|
|
25
|
+
association nullifies the foreign key unless it declares `dependent: :delete_all`.
|
|
26
|
+
|
|
27
|
+
## [0.1.0] - 2026-08-21
|
|
28
|
+
|
|
29
|
+
Initial release.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `ActiveRecord::Relation#update_all_returning(updates, returning: nil)` — runs an `UPDATE ... RETURNING`
|
|
34
|
+
over the current scope and returns the changed rows as an `ActiveRecord::Result`.
|
|
35
|
+
- `ActiveRecord::Relation#delete_all_returning(returning: nil)` — the same for `DELETE ... RETURNING`.
|
|
36
|
+
- `returning:` accepts a symbol, an array of symbols, `:_all` for `RETURNING *`, or `Arel.sql` for raw SQL.
|
|
37
|
+
It defaults to the primary key, including composite primary keys on Rails 7.1+. `:all` raises pointing at
|
|
38
|
+
`:_all` (it could be a real column name), and so does `:_all` combined with other columns.
|
|
39
|
+
- Optimistic locking support: the locking column is incremented exactly as `update_all` does, unless the
|
|
40
|
+
caller sets it explicitly.
|
|
41
|
+
- Both methods are also delegated onto the model class, like `update_all`, so `User.update_all_returning(...)`
|
|
42
|
+
works and not only `User.where(...).update_all_returning(...)`.
|
|
43
|
+
- `ActiveRecord::Returning::Error` and `ActiveRecord::Returning::UnsupportedAdapter`, raised on adapters
|
|
44
|
+
without `RETURNING` support (MySQL, MariaDB, SQLite older than 3.35), on eager-loaded relations, and on
|
|
45
|
+
models without a primary key.
|
|
46
|
+
|
|
47
|
+
### Notes
|
|
48
|
+
|
|
49
|
+
- The query cache is cleared after each statement, both thread-wide (as `update_all` does, so a
|
|
50
|
+
primary/replica setup does not keep a stale entry) and on the connection written through. Rails 7.0's
|
|
51
|
+
`exec_query` does not dirty the cache, and its thread-wide clear is itself a no-op under the default
|
|
52
|
+
`legacy_connection_handling`.
|
|
53
|
+
- A relation with `group` or `having` raises rather than building a subquery the database rejects, and an
|
|
54
|
+
empty `returning:` list raises instead of emitting a bare `RETURNING`.
|
|
55
|
+
- Adapter support prefers `supports_update_returning?` where it exists, falling back to
|
|
56
|
+
`supports_insert_returning?` — with the MySQL family excluded explicitly, because MariaDB answers
|
|
57
|
+
`supports_insert_returning?` with `true` (it has `INSERT ... RETURNING` since 10.5) while having no
|
|
58
|
+
`UPDATE ... RETURNING` at all. CI has a MariaDB lane.
|
|
59
|
+
- `alias_attribute` names are resolved in both `updates` and `returning:`, as `update_all` and `pluck` do.
|
|
60
|
+
A returned alias keeps the caller's name: `RETURNING "title" AS "headline"`.
|
|
61
|
+
- Active Record is capped at `< 8.2` because rails/rails#57073 proposes an upstream `update_all_returning`
|
|
62
|
+
with a different API. If a relation already defines these methods, the gem leaves them alone and warns.
|
|
63
|
+
|
|
64
|
+
- PostgreSQL and SQLite 3.35+ only. `ActiveRecord::Returning.supported?` decides, and the notes below
|
|
65
|
+
describe exactly how.
|
|
66
|
+
- Both methods are additive: nothing in Active Record is overridden or prepended.
|
|
67
|
+
- Callbacks, validations and timestamps are skipped, exactly as with `update_all`/`delete_all`.
|
|
68
|
+
|
|
69
|
+
[Unreleased]: https://github.com/igorkasyanchuk/activerecord-returning/compare/v0.1.0...HEAD
|
|
70
|
+
[0.1.0]: https://github.com/igorkasyanchuk/activerecord-returning/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Kasyanchuk
|
|
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,357 @@
|
|
|
1
|
+
# activerecord-returning
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/activerecord-returning)
|
|
4
|
+
[](https://github.com/igorkasyanchuk/activerecord-returning/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
**`update_all` tells you how many rows changed. This gem tells you which ones.**
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
User.where(role: :admin).update_all(role: :member)
|
|
12
|
+
# => 2 ...which two?
|
|
13
|
+
|
|
14
|
+
User.where(role: :admin).update_all_returning({ role: :member }, returning: %i[id email])
|
|
15
|
+
# => #<ActiveRecord::Result [[1, "ada@example.com"], [2, "grace@example.com"]]>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
One statement. No `pluck` first, no `FOR UPDATE`, no window where another process can change the set
|
|
19
|
+
underneath you.
|
|
20
|
+
|
|
21
|
+
## TL;DR
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "activerecord-returning"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# every form of update_all, plus returning:
|
|
29
|
+
User.where(role: :admin).update_all_returning(role: :member) # => primary keys
|
|
30
|
+
User.where(role: :admin).update_all_returning({ role: :member }, returning: :email)
|
|
31
|
+
User.where(role: :admin).update_all_returning({ role: :member }, returning: %i[id email])
|
|
32
|
+
User.where(role: :admin).update_all_returning({ role: :member }, returning: :_all) # RETURNING *
|
|
33
|
+
User.update_all_returning(role: :member) # on the model, too
|
|
34
|
+
|
|
35
|
+
Session.where(expires_at: ..1.week.ago).delete_all_returning(returning: :user_id)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Always returns an `ActiveRecord::Result`. PostgreSQL and SQLite 3.35+. Rails 7.0–8.1. Nothing is
|
|
39
|
+
overridden — `update_all` and `delete_all` behave exactly as before.
|
|
40
|
+
|
|
41
|
+
## Why
|
|
42
|
+
|
|
43
|
+
Every bulk change ends with the same question: *and then what?* Enqueue jobs for the rows you touched,
|
|
44
|
+
write an audit entry, send the mail, invalidate the cache. `update_all` hands back an Integer, so people
|
|
45
|
+
reach for one of these:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Racy. Another process can change the set between the two statements.
|
|
49
|
+
ids = Session.where(expires_at: ..1.week.ago).pluck(:id)
|
|
50
|
+
Session.where(id: ids).delete_all
|
|
51
|
+
|
|
52
|
+
# Correct, but three statements, a transaction and a lock you have to remember.
|
|
53
|
+
Session.transaction do
|
|
54
|
+
ids = Session.where(expires_at: ..1.week.ago).lock.pluck(:id)
|
|
55
|
+
Session.where(id: ids).delete_all
|
|
56
|
+
ids
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Databases have solved this for years with `RETURNING`. Rails exposes it on `insert_all`/`upsert_all` via a
|
|
61
|
+
`returning:` kwarg — but not on `update_all`/`delete_all`. Proposed upstream more than once, still not
|
|
62
|
+
merged as of Rails 8.1. This gem adds the two methods, using only public Active Record API.
|
|
63
|
+
|
|
64
|
+
## Examples
|
|
65
|
+
|
|
66
|
+
**Expire sessions, notify their owners**
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
expired = Session.where(expires_at: ..Time.current).delete_all_returning(returning: :user_id)
|
|
70
|
+
|
|
71
|
+
expired.rows.flatten.uniq.each { |user_id| SessionExpiredMailer.notify(user_id).deliver_later }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Claim work without a race**
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
claimed = Job.pending.order(:created_at).limit(1)
|
|
78
|
+
.update_all_returning({ status: :claimed, worker: worker_id }, returning: :_all)
|
|
79
|
+
|
|
80
|
+
job = Job.instantiate(claimed.to_a.first) if claimed.length.positive?
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two workers running that at once cannot claim the same row: the `UPDATE` picks it, and only one wins.
|
|
84
|
+
|
|
85
|
+
**Audit a bulk change**
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
changed = Invoice.where(status: :draft).update_all_returning(
|
|
89
|
+
{ status: :issued, issued_at: Time.current },
|
|
90
|
+
returning: %i[id number]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
AuditLog.insert_all(changed.to_a.map { |row| { subject: "Invoice##{row["id"]}", action: "issued" } })
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Cancel and report in one pass**
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
result = Subscription.where(trial_ends_at: ..Date.current)
|
|
100
|
+
.update_all_returning({ state: "expired" }, returning: %i[id user_id plan])
|
|
101
|
+
|
|
102
|
+
Rails.logger.info("expired #{result.length} trials: #{result.rows.inspect}")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Get models back**
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
users = User.where(role: :admin)
|
|
109
|
+
.update_all_returning({ role: :member }, returning: :_all)
|
|
110
|
+
.map { |attributes| User.instantiate(attributes) }
|
|
111
|
+
|
|
112
|
+
users.first.email # => "ada@example.com"
|
|
113
|
+
users.first.new_record? # => false
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Timestamps, if you want them** — like `update_all`, nothing is touched for you:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
User.where(role: :admin).update_all_returning(role: :member, updated_at: Time.current)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## `returning:`
|
|
123
|
+
|
|
124
|
+
| Value | Clause |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| omitted / `nil` | the primary key (all of them, for a composite primary key) |
|
|
127
|
+
| `:email` | `RETURNING "email"` |
|
|
128
|
+
| `%i[id email]` | `RETURNING "id", "email"` |
|
|
129
|
+
| `:_all` (bare or as `[:_all]`) | `RETURNING *` |
|
|
130
|
+
| `Arel.sql("id, now() AS at")` | that SQL, verbatim |
|
|
131
|
+
|
|
132
|
+
Rejected on purpose, each with a message saying what to do instead: a bare `String` (pass symbols, or wrap
|
|
133
|
+
SQL in `Arel.sql`), an empty list, `returning: false` (use plain `update_all`), `:all` (use `:_all`), and
|
|
134
|
+
`:_all` mixed with other columns (`RETURNING *` already includes them). A column literally named `all` or
|
|
135
|
+
`_all` is reachable with `Arel.sql`.
|
|
136
|
+
|
|
137
|
+
`updates` takes every shape `update_all` accepts:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
users.update_all_returning(role: :member) # Hash
|
|
141
|
+
users.update_all_returning("role = 0") # String
|
|
142
|
+
users.update_all_returning(["email = ?", "x@example.com"]) # Array
|
|
143
|
+
users.update_all_returning(role: :member, returning: :email) # braceless — returning: is pulled out
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Hash values go through the attribute's type, so enums, booleans, JSON and `alias_attribute` cast exactly as
|
|
147
|
+
`update_all` does.
|
|
148
|
+
|
|
149
|
+
## The return value
|
|
150
|
+
|
|
151
|
+
Always an `ActiveRecord::Result` — the same class `insert_all` returns, no wrapper, no subclass.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
result.columns # => ["id", "email"]
|
|
155
|
+
result.rows # => [[1, "ada@example.com"], [2, "grace@example.com"]]
|
|
156
|
+
result.to_a # => [{"id" => 1, "email" => "ada@example.com"}, ...]
|
|
157
|
+
result.length # => 2
|
|
158
|
+
result.each { |row| … }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Casting is the **adapter's**, not your model's: PostgreSQL types by OID and gives you a `Time` for a
|
|
162
|
+
`timestamp`; SQLite hands back the raw String. For model types:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
result.cast_values(Session.attribute_types) # => [[7, 1, 2026-08-14 09:00:00 UTC], ...]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Database support
|
|
169
|
+
|
|
170
|
+
| Database | Supported |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| PostgreSQL | yes |
|
|
173
|
+
| SQLite 3.35+ | yes |
|
|
174
|
+
| MySQL | no — raises `UnsupportedAdapter` |
|
|
175
|
+
| MariaDB | no — raises `UnsupportedAdapter` |
|
|
176
|
+
|
|
177
|
+
Read from `supports_update_returning?` where it exists, `supports_insert_returning?` otherwise. Two
|
|
178
|
+
deliberate exceptions:
|
|
179
|
+
|
|
180
|
+
- **MariaDB** answers `supports_insert_returning?` with `true` (it has `INSERT ... RETURNING` since 10.5)
|
|
181
|
+
while having no `UPDATE ... RETURNING` at all — so the MySQL family is excluded explicitly, and CI runs a
|
|
182
|
+
MariaDB lane to keep it that way.
|
|
183
|
+
- **Rails 7.0's SQLite3 adapter** predates those capability methods, so the SQLite version is checked
|
|
184
|
+
directly.
|
|
185
|
+
|
|
186
|
+
MySQL has no `RETURNING` on any statement. There is nothing to generate, so it raises rather than guessing:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
User.where(role: :admin).update_all_returning(role: :member)
|
|
190
|
+
# ActiveRecord::Returning::UnsupportedAdapter:
|
|
191
|
+
# the Trilogy adapter does not support RETURNING on UPDATE/DELETE
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
On MySQL, do it in two statements with a lock, inside a transaction:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
User.transaction do
|
|
198
|
+
ids = User.where(role: :admin).lock.pluck(:id)
|
|
199
|
+
User.where(id: ids).update_all(role: :member)
|
|
200
|
+
User.where(id: ids)
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The gem won't do that for you: it's only correct inside a transaction with a lock, and hiding that behind a
|
|
205
|
+
method that looks atomic hands a race to everyone who forgets.
|
|
206
|
+
|
|
207
|
+
## How it works
|
|
208
|
+
|
|
209
|
+
Your relation is reduced to a primary-key `SELECT` and used as a subquery:
|
|
210
|
+
|
|
211
|
+
```sql
|
|
212
|
+
UPDATE "users" SET "role" = 0, "lock_version" = COALESCE("lock_version", 0) + 1
|
|
213
|
+
WHERE "users"."id" IN (
|
|
214
|
+
SELECT "users"."id" FROM "users" WHERE "users"."role" = 1 ORDER BY "users"."email" ASC LIMIT 2
|
|
215
|
+
)
|
|
216
|
+
RETURNING "id", "email"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Active Record builds that inner `SELECT`, so everything you already know keeps working:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
User.joins(:posts).where(posts: { published: true }).update_all_returning(role: :member)
|
|
223
|
+
User.order(:created_at).limit(100).update_all_returning({ role: :member }, returning: :id)
|
|
224
|
+
User.where(role: :admin).lock.update_all_returning(role: :member) # FOR UPDATE in the subquery
|
|
225
|
+
user.posts.update_all_returning({ published: true }, returning: :id)
|
|
226
|
+
Memo.update_all_returning(title: "edited") # STI: this subclass only
|
|
227
|
+
Note.where(shop_id: 1).delete_all_returning # WHERE ("shop_id", "note_id") IN (SELECT ...)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Default scopes, `merge`, `none`, `distinct` and composite primary keys are covered too. No
|
|
231
|
+
`Arel::UpdateManager`, no `_substitute_values`, no `build_arel` — nothing private, which is why one code
|
|
232
|
+
path spans Rails 7.0 to 8.1.
|
|
233
|
+
|
|
234
|
+
**Optimistic locking** works like `update_all`: the locking column is incremented unless you set it
|
|
235
|
+
yourself.
|
|
236
|
+
|
|
237
|
+
## Caveats
|
|
238
|
+
|
|
239
|
+
**Callbacks, validations and timestamps are skipped**, exactly as with `update_all`/`delete_all`. Nothing
|
|
240
|
+
is instantiated.
|
|
241
|
+
|
|
242
|
+
**Isolation.** The subquery runs inside the same statement, so there is no separate read. But under `READ
|
|
243
|
+
COMMITTED` (PostgreSQL's default) a row whose value changed after the statement's snapshot can still be
|
|
244
|
+
picked up. Where that matters, lock explicitly or raise the isolation level.
|
|
245
|
+
|
|
246
|
+
**Rejected relations**, each with a message pointing at the fix:
|
|
247
|
+
|
|
248
|
+
| | why |
|
|
249
|
+
| --- | --- |
|
|
250
|
+
| `includes` that eager-loads | a join can't be reduced to a primary-key subquery — use `joins` |
|
|
251
|
+
| `group` / `having` | the subquery would select an ungrouped column — use `where(id: grouped.select(:id))` |
|
|
252
|
+
| `from` | it renames the table the subquery reads, so the primary key would resolve to the row being changed and every row would match — use `unscope(:from)` |
|
|
253
|
+
| model without a primary key | nothing to match rows on |
|
|
254
|
+
|
|
255
|
+
**`delete_all_returning` on a `has_many` deletes.** `user.posts.delete_all` nullifies `posts.user_id`
|
|
256
|
+
unless the association declares `dependent: :delete_all`. `delete_all_returning` always issues a `DELETE`,
|
|
257
|
+
because returning rows that still exist would be a lie. Renaming one call to the other on an association
|
|
258
|
+
without `dependent: :delete_all` therefore removes rows where the old code only unset a foreign key.
|
|
259
|
+
|
|
260
|
+
**`returning: :_all` on a joined relation** returns the updated table's columns only — `RETURNING *` refers
|
|
261
|
+
to the updated row, not the join.
|
|
262
|
+
|
|
263
|
+
**Performance.** The subquery is always there, even for a plain `where`, while `update_all` writes a direct
|
|
264
|
+
`UPDATE ... WHERE` unless a join or limit forces otherwise. PostgreSQL usually turns it into a semi-join on
|
|
265
|
+
the same index; on a large hot-path table, `EXPLAIN` first.
|
|
266
|
+
|
|
267
|
+
## Active Record may grow its own
|
|
268
|
+
|
|
269
|
+
[rails/rails#57073](https://github.com/rails/rails/pull/57073) proposes an `update_all_returning` upstream.
|
|
270
|
+
Still open, and its API differs:
|
|
271
|
+
|
|
272
|
+
| | rails/rails#57073 | this gem |
|
|
273
|
+
| --- | --- | --- |
|
|
274
|
+
| columns | `select(...)` on the relation | `returning:` keyword |
|
|
275
|
+
| default | all columns | the primary key |
|
|
276
|
+
|
|
277
|
+
So the gemspec caps Active Record at `< 8.2`, and if `ActiveRecord::Relation` already defines either
|
|
278
|
+
method, the gem leaves that one alone and warns at boot instead of silently doing nothing.
|
|
279
|
+
|
|
280
|
+
Compared to `insert_all`/`upsert_all`, which already have `returning:`: same return type, same default.
|
|
281
|
+
|
|
282
|
+
The difference that matters is not the keyword — **`upsert_all` creates rows, these methods never do.**
|
|
283
|
+
It takes a list of attributes rather than a scope, so it cannot express `where(...)`, and any key that
|
|
284
|
+
isn't in the table yet becomes a new row:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
User.upsert_all([{ id: 999, email: "ghost@example.com" }], returning: %i[id email])
|
|
288
|
+
# => #<ActiveRecord::Result [[999, "ghost@example.com"]]> looks like a row you changed
|
|
289
|
+
User.count # => 4 it was inserted
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
A stale id, a typo, a half-built payload — inserted, and handed back as though it had been updated.
|
|
293
|
+
`update_all_returning` can only touch rows the relation already matched.
|
|
294
|
+
|
|
295
|
+
Smaller differences: `:_all` is an addition here, `returning: false` raises instead of returning an empty
|
|
296
|
+
Result, and MySQL raises instead of quietly returning an empty Result.
|
|
297
|
+
|
|
298
|
+
## Why methods, not a chainable `.returning`
|
|
299
|
+
|
|
300
|
+
`User.returning(:id).where(...).update_all(...)` was considered and rejected:
|
|
301
|
+
|
|
302
|
+
1. `update_all` would return an Integer *or* a Result depending on state set somewhere else. The call site
|
|
303
|
+
stops being readable.
|
|
304
|
+
2. It needs `prepend` over `update_all`. Removing the gem would then silently change working code instead
|
|
305
|
+
of raising `NoMethodError` at the one place that used it.
|
|
306
|
+
3. Rails put `returning:` on `insert_all`/`upsert_all` as a keyword, not a query method.
|
|
307
|
+
|
|
308
|
+
The trade-off: you can't bake it into a scope. That's the intended cost.
|
|
309
|
+
|
|
310
|
+
## Errors
|
|
311
|
+
|
|
312
|
+
| Error | When |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `ActiveRecord::Returning::UnsupportedAdapter` | MySQL, MariaDB, SQLite < 3.35 |
|
|
315
|
+
| `ActiveRecord::Returning::Error` | eager loading, `group`/`having`, `from`, no primary key |
|
|
316
|
+
| `ArgumentError` | empty updates, bare String / empty list / `false` in `returning:` |
|
|
317
|
+
|
|
318
|
+
`UnsupportedAdapter < Error < StandardError`.
|
|
319
|
+
|
|
320
|
+
## Requirements
|
|
321
|
+
|
|
322
|
+
Ruby 3.1+ · Active Record 7.0–8.1 · PostgreSQL or SQLite 3.35+
|
|
323
|
+
|
|
324
|
+
CI covers Ruby 3.1–3.4 × Rails 7.0, 7.1, 7.2, 8.0, 8.1 on SQLite, PostgreSQL on every one of those Rails
|
|
325
|
+
versions, and MySQL 8 and MariaDB 11 for the unsupported-adapter path.
|
|
326
|
+
|
|
327
|
+
## Development
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
bin/setup # create the dev database, load the schema, seed it
|
|
331
|
+
bin/console # IRB with User, Post, Session, Note (composite PK), Memo (STI)
|
|
332
|
+
bundle exec rake test
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
All three take `DB=sqlite` (default), `DB=postgres`, `DB=mysql` or `DB=mariadb`; databases are created for
|
|
336
|
+
you, connections come from `PGHOST`/`PGUSER`/`PGPASSWORD` and `MYSQL_HOST`/`MYSQL_PORT`/`MYSQL_USER`/
|
|
337
|
+
`MYSQL_PASSWORD`.
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
bundle exec appraisal install && bundle exec appraisal rake test # every supported Rails
|
|
341
|
+
python3 docs/render_demo.py # re-render the demo GIF
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Releasing
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
# bump lib/activerecord/returning/version.rb, move CHANGELOG entries under the new version
|
|
348
|
+
bundle exec rake release
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Contributing
|
|
352
|
+
|
|
353
|
+
Bug reports and pull requests: https://github.com/igorkasyanchuk/activerecord-returning
|
|
354
|
+
|
|
355
|
+
## License
|
|
356
|
+
|
|
357
|
+
MIT. Copyright (c) 2026 Igor Kasyanchuk.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Returning
|
|
5
|
+
# Base class for every error this gem raises.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the connection's adapter cannot run a RETURNING clause
|
|
9
|
+
# (MySQL, or SQLite older than 3.35).
|
|
10
|
+
class UnsupportedAdapter < Error; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# delegate is an Active Support core extension, and this file is loaded before
|
|
4
|
+
# Active Record boots.
|
|
5
|
+
require "active_support/core_ext/module/delegation"
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module Returning
|
|
9
|
+
# Extended into ActiveRecord::Base, so the methods can be called on the model
|
|
10
|
+
# itself and not only on a relation — the same delegation Rails uses to put
|
|
11
|
+
# update_all and delete_all on the class.
|
|
12
|
+
#
|
|
13
|
+
# User.update_all_returning(role: :member)
|
|
14
|
+
module Querying
|
|
15
|
+
delegate :update_all_returning, :delete_all_returning, to: :all
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Returning
|
|
5
|
+
# Mixed into ActiveRecord::Relation. Deliberately holds nothing but the two
|
|
6
|
+
# public methods: every helper lives on Statement so this module can never
|
|
7
|
+
# collide with Active Record's own internals.
|
|
8
|
+
module RelationMethods
|
|
9
|
+
# Runs an UPDATE over the current scope and returns the changed rows.
|
|
10
|
+
#
|
|
11
|
+
# User.where(role: :admin).update_all_returning({ role: :user }, returning: %i[id email])
|
|
12
|
+
# # => #<ActiveRecord::Result @columns=["id", "email"], @rows=[[1, "ada@example.com"]]>
|
|
13
|
+
#
|
|
14
|
+
# +updates+ takes the same shapes as #update_all (Hash, String, Array). A
|
|
15
|
+
# braceless hash works too, so `update_all_returning(role: :user, returning: :id)`
|
|
16
|
+
# is the same call. +returning+ defaults to the primary key.
|
|
17
|
+
def update_all_returning(updates = nil, returning: nil, **rest)
|
|
18
|
+
if updates && rest.any?
|
|
19
|
+
raise ArgumentError, "unknown keywords: #{rest.keys.map(&:inspect).join(", ")}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Statement.new(self, returning: returning).update(updates || rest)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs a DELETE over the current scope and returns the deleted rows.
|
|
26
|
+
#
|
|
27
|
+
# Session.where(expires_at: ..1.week.ago).delete_all_returning(returning: %i[id user_id])
|
|
28
|
+
def delete_all_returning(returning: nil)
|
|
29
|
+
Statement.new(self, returning: returning).delete
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Returning
|
|
5
|
+
# Builds and runs the UPDATE/DELETE ... RETURNING statement for a relation.
|
|
6
|
+
#
|
|
7
|
+
# The relation itself is never rebuilt into Arel by hand. It is reduced to a
|
|
8
|
+
# primary-key SELECT and used as a subquery:
|
|
9
|
+
#
|
|
10
|
+
# UPDATE "users" SET "role" = 'user'
|
|
11
|
+
# WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE "users"."role" = 'admin')
|
|
12
|
+
# RETURNING "id", "email"
|
|
13
|
+
#
|
|
14
|
+
# so default scopes, joins, limit, order, merge, none and composite primary
|
|
15
|
+
# keys all keep working because Active Record builds that SELECT.
|
|
16
|
+
class Statement
|
|
17
|
+
def initialize(relation, returning: nil)
|
|
18
|
+
@relation = relation
|
|
19
|
+
@klass = relation.klass
|
|
20
|
+
@returning = returning
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update(updates)
|
|
24
|
+
raise ArgumentError, "Empty list of attributes to change" if updates.blank?
|
|
25
|
+
|
|
26
|
+
run("Update All Returning") do |conn|
|
|
27
|
+
"UPDATE #{quoted_table_name(conn)} SET #{set_clause(conn, updates)} " \
|
|
28
|
+
"WHERE #{where_clause(conn)} RETURNING #{returning_clause(conn)}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete
|
|
33
|
+
run("Delete All Returning") do |conn|
|
|
34
|
+
"DELETE FROM #{quoted_table_name(conn)} " \
|
|
35
|
+
"WHERE #{where_clause(conn)} RETURNING #{returning_clause(conn)}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
attr_reader :relation, :klass, :returning
|
|
41
|
+
|
|
42
|
+
def run(name)
|
|
43
|
+
with_connection do |conn|
|
|
44
|
+
validate!(conn)
|
|
45
|
+
result = conn.exec_query(yield(conn), name)
|
|
46
|
+
|
|
47
|
+
clear_query_caches(conn)
|
|
48
|
+
relation.reset # loaded records are stale now
|
|
49
|
+
result
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# exec_query only dirties the query cache from Rails 7.1 on, and a cached
|
|
54
|
+
# SELECT of a row we just changed is stale.
|
|
55
|
+
#
|
|
56
|
+
# Both clears are deliberate. The thread-wide one is what update_all uses,
|
|
57
|
+
# and is the one that matters for a primary/replica setup, where the stale
|
|
58
|
+
# entry can sit in another pool. It is also a no-op on Rails 7.0 with the
|
|
59
|
+
# default legacy_connection_handling, where it walks connection_handlers
|
|
60
|
+
# and misses the handler actually in use — Rails 7.0's own update_all
|
|
61
|
+
# leaves the cache stale for the same reason. So the connection we wrote
|
|
62
|
+
# through is cleared directly as well.
|
|
63
|
+
def clear_query_caches(conn)
|
|
64
|
+
ActiveRecord::Base.clear_query_caches_for_current_thread if
|
|
65
|
+
ActiveRecord::Base.respond_to?(:clear_query_caches_for_current_thread)
|
|
66
|
+
|
|
67
|
+
conn.clear_query_cache
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Rails 7.2 deprecated holding on to a connection via klass.connection.
|
|
71
|
+
def with_connection(&block)
|
|
72
|
+
if klass.respond_to?(:with_connection)
|
|
73
|
+
klass.with_connection(&block)
|
|
74
|
+
else
|
|
75
|
+
block.call(klass.connection)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate!(conn)
|
|
80
|
+
unless Returning.supported?(conn)
|
|
81
|
+
raise UnsupportedAdapter,
|
|
82
|
+
"the #{conn.adapter_name} adapter does not support RETURNING on UPDATE/DELETE"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if relation.eager_loading?
|
|
86
|
+
raise Error,
|
|
87
|
+
"#{self.class.name} cannot be used with eager loading, because an `includes` that " \
|
|
88
|
+
"becomes a join cannot be reduced to a primary key subquery. Use `.joins` instead, " \
|
|
89
|
+
"or `.unscope(:includes)`."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise Error, "#{klass.name} has no primary key, so there is nothing to match rows on" if primary_keys.empty?
|
|
93
|
+
|
|
94
|
+
unless relation.from_clause.empty?
|
|
95
|
+
raise Error,
|
|
96
|
+
"#{self.class.name} cannot be used with `from`, because the primary key subquery selects " \
|
|
97
|
+
"#{primary_keys.map { |name| "#{klass.table_name}.#{name}" }.join(", ")} while `from` renames " \
|
|
98
|
+
"the table it reads. " \
|
|
99
|
+
"The database then reads that as a reference to the row being changed and matches every " \
|
|
100
|
+
"row in the table. Use `.unscope(:from)`."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if relation.group_values.any? || !relation.having_clause.empty?
|
|
104
|
+
raise Error,
|
|
105
|
+
"#{self.class.name} cannot be used with group or having: the primary key subquery would " \
|
|
106
|
+
"select a column that is not grouped. Reduce the relation to plain conditions first, for " \
|
|
107
|
+
"example with `where(id: grouped_relation.select(:id))`."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def set_clause(conn, updates)
|
|
112
|
+
set = klass.sanitize_sql_for_assignment(resolve_aliases(updates))
|
|
113
|
+
set += ", #{increment_lock_version(conn)}" if increment_lock_version?(updates)
|
|
114
|
+
set
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# alias_attribute names are not columns, so they have to be resolved
|
|
118
|
+
# before the SET clause is built. update_all does the same.
|
|
119
|
+
def resolve_aliases(updates)
|
|
120
|
+
return updates unless updates.is_a?(Hash) && klass.attribute_aliases.any?
|
|
121
|
+
|
|
122
|
+
updates.transform_keys { |key| klass.attribute_aliases[key.to_s] || key }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Matches update_all: bump the lock column unless the caller set it.
|
|
126
|
+
def increment_lock_version?(updates)
|
|
127
|
+
return false unless klass.locking_enabled?
|
|
128
|
+
return false unless updates.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
updates = resolve_aliases(updates)
|
|
131
|
+
|
|
132
|
+
column = klass.locking_column
|
|
133
|
+
!updates.key?(column) && !updates.key?(column.to_sym)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def increment_lock_version(conn)
|
|
137
|
+
column = conn.quote_column_name(klass.locking_column)
|
|
138
|
+
"#{column} = COALESCE(#{column}, 0) + 1"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def where_clause(conn)
|
|
142
|
+
columns = primary_keys.map { |name| "#{quoted_table_name(conn)}.#{conn.quote_column_name(name)}" }
|
|
143
|
+
left = columns.one? ? columns.first : "(#{columns.join(", ")})"
|
|
144
|
+
|
|
145
|
+
"#{left} IN (#{subquery_sql})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Arel attributes, not bare symbols, so the primary key stays
|
|
149
|
+
# table-qualified and cannot go ambiguous under a join.
|
|
150
|
+
def subquery_sql
|
|
151
|
+
attributes = primary_keys.map { |name| klass.arel_table[name] }
|
|
152
|
+
relation.unscope(:select).select(*attributes).to_sql
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def returning_clause(conn)
|
|
156
|
+
return primary_keys.map { |name| conn.quote_column_name(name) }.join(", ") if returning.nil?
|
|
157
|
+
|
|
158
|
+
# Normalized once, so every special value is checked in one place,
|
|
159
|
+
# bare or listed. The sentinel checks compare symbols, and they must
|
|
160
|
+
# run before the column path: on SQLite an unknown double-quoted
|
|
161
|
+
# identifier does not error, it falls back to a string literal —
|
|
162
|
+
# silent wrong data instead of an exception.
|
|
163
|
+
columns = Array(returning)
|
|
164
|
+
raise ArgumentError, "returning: is empty, so there is nothing to return" if columns.empty?
|
|
165
|
+
|
|
166
|
+
if columns.include?(false)
|
|
167
|
+
raise ArgumentError,
|
|
168
|
+
"returning: false is not supported, because these methods always return rows. " \
|
|
169
|
+
"Use update_all/delete_all if you only want the count."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if columns.include?(:all)
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
":all was renamed to :_all, and RETURNING * cannot be combined with other columns — " \
|
|
175
|
+
"use returning: :_all alone. For a column literally named \"all\", use Arel.sql('\"all\"')."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ponytail: a column literally named _all is shadowed by the sentinel; Arel.sql is the escape.
|
|
179
|
+
if columns.include?(:_all)
|
|
180
|
+
return "*" if columns.uniq == [:_all]
|
|
181
|
+
|
|
182
|
+
raise ArgumentError,
|
|
183
|
+
"returning: :_all stands for RETURNING * and cannot be combined with other columns. " \
|
|
184
|
+
"Use returning: :_all alone, or list every column. For a column literally named " \
|
|
185
|
+
"\"_all\", use Arel.sql('\"_all\"')."
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
columns.map { |column| returning_column(conn, column) }.join(", ")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def returning_column(conn, column)
|
|
192
|
+
# SqlLiteral is a String subclass, so it has to be checked first.
|
|
193
|
+
case column
|
|
194
|
+
when Arel::Nodes::SqlLiteral then column.to_s
|
|
195
|
+
when Symbol then returning_attribute(conn, column)
|
|
196
|
+
when String
|
|
197
|
+
raise ArgumentError,
|
|
198
|
+
"returning: does not take raw String #{column.inspect}. Pass column names as symbols " \
|
|
199
|
+
"(returning: :id, returning: %i[id email]) or wrap SQL in Arel.sql."
|
|
200
|
+
else
|
|
201
|
+
raise ArgumentError, "unsupported returning: value #{column.inspect}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# An alias_attribute is not a column, so it has to be resolved — and then
|
|
206
|
+
# aliased back, so the caller reads the result under the name they asked
|
|
207
|
+
# for: RETURNING "title" AS "headline".
|
|
208
|
+
def returning_attribute(conn, name)
|
|
209
|
+
column = klass.attribute_aliases[name.to_s]
|
|
210
|
+
return conn.quote_column_name(name) if column.nil?
|
|
211
|
+
|
|
212
|
+
"#{conn.quote_column_name(column)} AS #{conn.quote_column_name(name)}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def primary_keys
|
|
216
|
+
@primary_keys ||= Array(klass.primary_key)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def quoted_table_name(conn)
|
|
220
|
+
conn.quote_table_name(klass.table_name)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/lazy_load_hooks"
|
|
4
|
+
|
|
5
|
+
require_relative "returning/version"
|
|
6
|
+
require_relative "returning/errors"
|
|
7
|
+
require_relative "returning/relation_methods"
|
|
8
|
+
require_relative "returning/querying"
|
|
9
|
+
|
|
10
|
+
module ActiveRecord
|
|
11
|
+
# UPDATE ... RETURNING and DELETE ... RETURNING for ActiveRecord::Relation.
|
|
12
|
+
module Returning
|
|
13
|
+
# Can this connection run UPDATE/DELETE ... RETURNING?
|
|
14
|
+
#
|
|
15
|
+
# supports_update_returning? is the precise question, but it only exists on
|
|
16
|
+
# newer Rails. supports_insert_returning? is the closest stand-in, with one
|
|
17
|
+
# trap: MariaDB answers true to it (it has INSERT ... RETURNING since 10.5)
|
|
18
|
+
# while having no UPDATE ... RETURNING at all, so the MySQL family is ruled
|
|
19
|
+
# out explicitly.
|
|
20
|
+
#
|
|
21
|
+
# The SQLite branch is for Rails 7.0, whose SQLite3 adapter predates the
|
|
22
|
+
# capability methods even though the database itself supports RETURNING.
|
|
23
|
+
# Version compares itself to a version string by parts; a plain string
|
|
24
|
+
# compare would read "3.4.0" as newer than "3.35.0".
|
|
25
|
+
def self.supported?(connection)
|
|
26
|
+
return connection.supports_update_returning? if connection.respond_to?(:supports_update_returning?)
|
|
27
|
+
|
|
28
|
+
adapter = connection.adapter_name.to_s
|
|
29
|
+
return false if adapter.match?(/mysql|trilogy|mariadb/i)
|
|
30
|
+
return true if connection.supports_insert_returning?
|
|
31
|
+
|
|
32
|
+
adapter.match?(/sqlite/i) && connection.database_version >= "3.35.0"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Loading this gem must not boot Active Record.
|
|
38
|
+
ActiveSupport.on_load(:active_record) do
|
|
39
|
+
require_relative "returning/statement"
|
|
40
|
+
|
|
41
|
+
# Active Record may grow its own update_all_returning one day (rails/rails#57073
|
|
42
|
+
# proposes one with a different API: columns via select(), defaulting to all of
|
|
43
|
+
# them). An include cannot override a method defined on Relation itself, so
|
|
44
|
+
# rather than silently doing nothing, say so.
|
|
45
|
+
taken = %i[update_all_returning delete_all_returning].select do |name|
|
|
46
|
+
ActiveRecord::Relation.method_defined?(name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if taken.any?
|
|
50
|
+
warn "[activerecord-returning] ActiveRecord::Relation already defines #{taken.join(" and ")}. " \
|
|
51
|
+
"Leaving #{taken.one? ? "that one" : "them"} alone. Note that Active Record's own version " \
|
|
52
|
+
"selects columns with select(), not with returning:."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add only the names Active Record has not taken, so one upstream method does
|
|
56
|
+
# not silently remove the other.
|
|
57
|
+
without_taken = lambda do |mixin|
|
|
58
|
+
next mixin if taken.empty?
|
|
59
|
+
|
|
60
|
+
mixin.dup.tap { |copy| taken.each { |name| copy.send(:remove_method, name) } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
ActiveRecord::Relation.include(without_taken.call(ActiveRecord::Returning::RelationMethods))
|
|
64
|
+
ActiveRecord::Base.extend(without_taken.call(ActiveRecord::Returning::Querying))
|
|
65
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-returning
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Igor Kasyanchuk
|
|
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: '7.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.2'
|
|
32
|
+
description: Adds update_all_returning and delete_all_returning to ActiveRecord::Relation,
|
|
33
|
+
so you get the changed rows back instead of a row count. PostgreSQL and SQLite 3.35+.
|
|
34
|
+
email:
|
|
35
|
+
- igorkasyanchuk@gmail.com
|
|
36
|
+
executables: []
|
|
37
|
+
extensions: []
|
|
38
|
+
extra_rdoc_files: []
|
|
39
|
+
files:
|
|
40
|
+
- CHANGELOG.md
|
|
41
|
+
- LICENSE.txt
|
|
42
|
+
- README.md
|
|
43
|
+
- lib/activerecord-returning.rb
|
|
44
|
+
- lib/activerecord/returning.rb
|
|
45
|
+
- lib/activerecord/returning/errors.rb
|
|
46
|
+
- lib/activerecord/returning/querying.rb
|
|
47
|
+
- lib/activerecord/returning/relation_methods.rb
|
|
48
|
+
- lib/activerecord/returning/statement.rb
|
|
49
|
+
- lib/activerecord/returning/version.rb
|
|
50
|
+
homepage: https://github.com/igorkasyanchuk/activerecord-returning
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
source_code_uri: https://github.com/igorkasyanchuk/activerecord-returning
|
|
55
|
+
changelog_uri: https://github.com/igorkasyanchuk/activerecord-returning/blob/main/CHANGELOG.md
|
|
56
|
+
bug_tracker_uri: https://github.com/igorkasyanchuk/activerecord-returning/issues
|
|
57
|
+
documentation_uri: https://github.com/igorkasyanchuk/activerecord-returning/blob/main/README.md
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '3.1'
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.7.2
|
|
74
|
+
specification_version: 4
|
|
75
|
+
summary: UPDATE ... RETURNING and DELETE ... RETURNING for ActiveRecord::Relation
|
|
76
|
+
test_files: []
|