active_record-update_when 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/MIT-LICENSE +20 -0
- data/README.md +95 -0
- data/Rakefile +11 -0
- data/lib/active_record/update_when/version.rb +8 -0
- data/lib/active_record/update_when.rb +125 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 110b4ea48f85769e8a5cba616ed2a31b7aa1147449e8442a6dff525a6acd098d
|
|
4
|
+
data.tar.gz: 26499a97f0d835cf5127adaffb3e57457171d9cfad370e3084c9f6d9139a72b1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bf98d801d91afd4b516a0c906c71511cec3de26659158de7b44053e29b0704493f72be13ecafc4fcaf217e00a1c60dca2aa9438c565bf0d6f051714490e9c4aa
|
|
7
|
+
data.tar.gz: 78ae5f467b5e9e10d8a5818fb9e9485a4297a12752d297e6ade3871f19873d92f707795dd72b47bdf7baf5818e212e6626683554f7b9005d866e4f993db8c256
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Minty Fresh
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# ActiveRecord::UpdateWhen
|
|
2
|
+
|
|
3
|
+
Updates ActiveRecord records only when they match a condition in the database.
|
|
4
|
+
|
|
5
|
+
The condition is part of the UPDATE statement itself, so it can't change between checking it and updating the record
|
|
6
|
+
(eg. by a concurrent update).
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add this line to your application's Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'active_record-update_when', github: 'mintyfresh/active_record-update_when'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
And then execute:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
$ bundle
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then include `ActiveRecord::UpdateWhen` in your models (typically in `ApplicationRecord`, so all models get it):
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
26
|
+
primary_abstract_class
|
|
27
|
+
|
|
28
|
+
include ActiveRecord::UpdateWhen
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
`update_when` updates a record (as `update` would) if it matches the condition in the database:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
order = Order.find(...)
|
|
38
|
+
|
|
39
|
+
if order.update_when(condition: Order.checked_out, attributes: { status: 'cancelled' })
|
|
40
|
+
# order was cancelled
|
|
41
|
+
elsif order.errors.any?
|
|
42
|
+
# order was invalid
|
|
43
|
+
else
|
|
44
|
+
# order wasn't checked-out
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```sql
|
|
49
|
+
UPDATE "orders" SET "status" = 'cancelled', "updated_at" = ...
|
|
50
|
+
WHERE "orders"."id" = 1 AND "orders"."status" = 'checked_out'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It returns false if the record is invalid (with errors), or if it doesn't match the condition (without errors).
|
|
54
|
+
When the condition isn't met, the update is rolled back, including any changes made by its callbacks
|
|
55
|
+
(even within another transaction).
|
|
56
|
+
|
|
57
|
+
`update_when!` updates the record as `update!` would. It returns false if the record doesn't match the condition,
|
|
58
|
+
and raises whenever `update!` would (eg. `ActiveRecord::RecordInvalid`):
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
order.update_when!(condition: Order.checked_out, attributes: { status: 'cancelled' })
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Conditions
|
|
65
|
+
|
|
66
|
+
The condition is a relation of the record's model. Its WHERE clause is added to the UPDATE:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
order.update_when(condition: Order.where(status: %w[pending checked_out]), attributes: { status: 'cancelled' })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Conditions can also have joins, GROUP BY and HAVING clauses (including for models with composite primary keys),
|
|
73
|
+
in which case the record is matched by an EXISTS subquery:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
order.update_when(condition: Order.checked_out.joins(:lines), attributes: { status: 'cancelled' })
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If the attributes don't change the record, nothing is updated (as with `update`) and the condition isn't checked.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
The specs run against a dummy Rails application in `spec/dummy`, using SQLite by default:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
$ bin/rspec
|
|
87
|
+
$ bin/rubocop
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
PostgreSQL and MySQL are selected with the `DATABASE_ADAPTER` and `DATABASE_URL` environment variables
|
|
91
|
+
(see `.github/workflows/ci.yml`).
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'active_record'
|
|
5
|
+
require 'active_record/update_when/version'
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
# Updates records only when they match a condition in the database, as part of the UPDATE statement itself,
|
|
9
|
+
# so that the condition can't change between checking it and updating the record (eg. by a concurrent update):
|
|
10
|
+
#
|
|
11
|
+
# class ApplicationRecord < ActiveRecord::Base
|
|
12
|
+
# include ActiveRecord::UpdateWhen
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# if order.update_when(condition: Order.checked_out, attributes: { status: 'cancelled' })
|
|
16
|
+
# # the order was cancelled
|
|
17
|
+
# elsif order.errors.any?
|
|
18
|
+
# # the order was invalid
|
|
19
|
+
# else
|
|
20
|
+
# # the order wasn't checked-out
|
|
21
|
+
# end
|
|
22
|
+
module UpdateWhen
|
|
23
|
+
# Raised from within the update when the record doesn't match the condition, to roll it back.
|
|
24
|
+
class ConditionNotMet < ActiveRecord::ActiveRecordError
|
|
25
|
+
end
|
|
26
|
+
private_constant :ConditionNotMet
|
|
27
|
+
|
|
28
|
+
# Updates the record (see `update`) if it matches the condition in the database.
|
|
29
|
+
# Returns false if the record is invalid (with errors), or if it doesn't match the condition (without errors),
|
|
30
|
+
# in which case the update is rolled back (including any changes made by its callbacks).
|
|
31
|
+
#
|
|
32
|
+
# The condition can have joins, GROUP BY and HAVING clauses (see `update_when_scope`).
|
|
33
|
+
# If the attributes don't change the record, nothing is updated and the condition isn't checked.
|
|
34
|
+
#
|
|
35
|
+
#: (condition: ActiveRecord::Relation, attributes: Hash[Symbol | String, untyped]) -> bool
|
|
36
|
+
def update_when(condition:, attributes:)
|
|
37
|
+
with_update_when_condition(condition) { update(attributes) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Updates the record (see `update!`) if it matches the condition in the database.
|
|
41
|
+
# Returns false if the record doesn't match the condition (see `update_when`),
|
|
42
|
+
# and raises whenever `update!` would (eg. if the record is invalid).
|
|
43
|
+
#
|
|
44
|
+
#: (condition: ActiveRecord::Relation, attributes: Hash[Symbol | String, untyped]) -> bool
|
|
45
|
+
def update_when!(condition:, attributes:)
|
|
46
|
+
with_update_when_condition(condition) { update!(attributes) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Updates within a savepoint, so that an unmatched condition rolls back the update's callbacks
|
|
52
|
+
# even within another transaction (which `update` would otherwise join).
|
|
53
|
+
#
|
|
54
|
+
#: (ActiveRecord::Relation condition) { () -> bool } -> bool
|
|
55
|
+
def with_update_when_condition(condition, &block)
|
|
56
|
+
validate_update_when_condition!(condition)
|
|
57
|
+
|
|
58
|
+
@update_when_condition = condition
|
|
59
|
+
self.class.transaction(requires_new: true, &block)
|
|
60
|
+
rescue ConditionNotMet
|
|
61
|
+
false
|
|
62
|
+
ensure
|
|
63
|
+
@update_when_condition = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#: (untyped condition) -> void
|
|
67
|
+
def validate_update_when_condition!(condition)
|
|
68
|
+
persisted? or
|
|
69
|
+
raise ArgumentError, "Can't conditionally update a #{self.class.name} which isn't persisted."
|
|
70
|
+
|
|
71
|
+
(condition.is_a?(ActiveRecord::Relation) && condition.model.base_class == self.class.base_class) or
|
|
72
|
+
raise ArgumentError, "Expected a relation of `#{self.class.base_class.name}`, got #{condition.inspect}."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Constrains the UPDATE by the WHERE clause of the condition (see `ActiveRecord::Persistence._update_record`).
|
|
76
|
+
#
|
|
77
|
+
#: (Array[String] attribute_names, ?String attempted_action) -> Integer
|
|
78
|
+
def _update_row(attribute_names, attempted_action = 'update')
|
|
79
|
+
condition = @update_when_condition
|
|
80
|
+
return super if condition.nil?
|
|
81
|
+
|
|
82
|
+
affected_rows = update_when_scope(condition).scoping(all_queries: true) { super }
|
|
83
|
+
|
|
84
|
+
affected_rows.positive? or
|
|
85
|
+
raise ConditionNotMet, "#{self.class.name} #{attempted_action} matched no rows"
|
|
86
|
+
|
|
87
|
+
affected_rows
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Only the WHERE clause of a scope applies to the UPDATE, so conditions with other clauses (eg. joins)
|
|
91
|
+
# are matched by an EXISTS subquery of the primary keys of the records which match them.
|
|
92
|
+
#
|
|
93
|
+
# The subquery selects from a derived table, since MySQL can't otherwise select from the table being updated,
|
|
94
|
+
# and is DISTINCT, so that MySQL doesn't merge the derived table back into the subquery
|
|
95
|
+
# (as Rails' MySQL adapter does for `update_all`).
|
|
96
|
+
#
|
|
97
|
+
#: (ActiveRecord::Relation condition) -> ActiveRecord::Relation
|
|
98
|
+
def update_when_scope(condition)
|
|
99
|
+
return condition if where_clause_only?(condition)
|
|
100
|
+
|
|
101
|
+
model = self.class
|
|
102
|
+
primary_key = Array(model.primary_key)
|
|
103
|
+
matching_table = Arel::Table.new('update_when_condition')
|
|
104
|
+
matching = condition.unscope(:order).select(*primary_key.map { |column| model.arel_table[column] }).distinct
|
|
105
|
+
|
|
106
|
+
model.unscoped.where(
|
|
107
|
+
model.unscoped.from(matching, model.adapter_class.quote_table_name(matching_table.name)).select(Arel.sql('1'))
|
|
108
|
+
.where(same_record_condition(matching_table, primary_key)).arel.exists
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: (Arel::Table matching_table, Array[String] primary_key) -> Arel::Nodes::Node
|
|
113
|
+
def same_record_condition(matching_table, primary_key)
|
|
114
|
+
table = self.class.arel_table
|
|
115
|
+
|
|
116
|
+
Arel::Nodes::And.new(primary_key.map { |column| matching_table[column].eq(table[column]) })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (ActiveRecord::Relation condition) -> bool
|
|
120
|
+
def where_clause_only?(condition)
|
|
121
|
+
condition.joins_values.empty? && condition.left_outer_joins_values.empty? &&
|
|
122
|
+
condition.group_values.empty? && condition.having_clause.empty?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_record-update_when
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Minty Fresh
|
|
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.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
description: Updates ActiveRecord records only when they match a condition in the
|
|
27
|
+
database, atomically, as part of the UPDATE statement.
|
|
28
|
+
email:
|
|
29
|
+
- 7896757+mintyfresh@users.noreply.github.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- lib/active_record/update_when.rb
|
|
38
|
+
- lib/active_record/update_when/version.rb
|
|
39
|
+
homepage: https://github.com/mintyfresh/active_record-update_when
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://github.com/mintyfresh/active_record-update_when
|
|
44
|
+
source_code_uri: https://github.com/mintyfresh/active_record-update_when
|
|
45
|
+
rubygems_mfa_required: 'true'
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.2'
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 4.0.20
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Conditional updates for ActiveRecord records
|
|
63
|
+
test_files: []
|