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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'rubocop/rake_task'
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+
11
+ task default: %i[spec rubocop]
@@ -0,0 +1,8 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module UpdateWhen
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -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: []