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 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
+ [![Gem Version](https://badge.fury.io/rb/activerecord-returning.svg)](https://rubygems.org/gems/activerecord-returning)
4
+ [![CI](https://github.com/igorkasyanchuk/activerecord-returning/actions/workflows/ci.yml/badge.svg)](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
+ ![update_all returns a count; update_all_returning returns the rows](docs/demo.gif)
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Returning
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "activerecord/returning"
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: []