has_state_machine 1.2.0 → 1.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d16c6b958f2e470af9f3120259e802badad7fcd6b8a4ea1d004c84f64e8d09d
4
- data.tar.gz: 2f9933db38477a1b1020303ab9848aa122fe7e699d153315b830144ffdbfd1a6
3
+ metadata.gz: 4f9619070408aa690d1e8b70c31baf787c91d949c0e28665a42af8b17ff79955
4
+ data.tar.gz: '099784a11f816d49d75b18432a1497688838aac08a775d15fe5d29827eb7ac38'
5
5
  SHA512:
6
- metadata.gz: 35f89aad901be7f976ee42573449c0d7412a633285e41f869f50d7be3215da2752470cf5c4b1299d483605f90b8925f5baa5065729b06f2121678518beeafdb4
7
- data.tar.gz: cb4e1a772fa2aa9828adfd3ab6bb548e5f489f403f4574cc464e9fa77f4633b886de5c486e4bb9b014eeb71c8ede380fab19320f8b85023e9643b38b3b9866c8
6
+ metadata.gz: 749b711582fc925fd8efbfcb7dcdee7dfb3f9902e945d5cc26e1f7f3737ea77f646087a117ccf9e6675444a858acb2497ae52dba0a3c8b2777628ebae33297af
7
+ data.tar.gz: a8cbe864ada7ab74316c55c14704ea33fa18bae5050d3a728d2f5458cb16b561419643805af0824fd7bdcca10cf42e5023e708d84f88ed1e3478a9b24bad2d43
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # HasStateMachine
2
2
 
3
3
  [![CI](https://github.com/beehiiv/has_state_machine/actions/workflows/ci.yml/badge.svg)](https://github.com/beehiiv/has_state_machine/actions/workflows/ci.yml)
4
- [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
5
5
 
6
6
  HasStateMachine uses ruby classes to make creating a finite state machine for your ActiveRecord models a breeze.
7
7
 
@@ -98,8 +98,10 @@ module Workflow
98
98
  end
99
99
 
100
100
  # after_transition_commit runs only once the transition has been
101
- # committed: after the record is saved for normal transitions, and
102
- # outside the transaction for transactional transitions (see below).
101
+ # committed to the database: after the OUTERMOST database transaction
102
+ # commits, or immediately after the transition when no transaction is
103
+ # open. It never runs if the transition or a surrounding transaction
104
+ # rolls back. (Requires Rails 7.2+; see Advanced Usage below.)
103
105
  after_transition_commit do
104
106
  MyJob.perform_later(object)
105
107
  end
@@ -232,6 +234,20 @@ module Workflow
232
234
  end
233
235
  ```
234
236
 
237
+ #### `after_transition_commit` Semantics
238
+
239
+ `after_transition_commit` callbacks run after the outermost database transaction commits. In detail:
240
+
241
+ - If no transaction is open when `transition_to` is called, they run immediately after the transition commits.
242
+ - If the transition happens inside an open transaction — an app-level `ActiveRecord::Base.transaction`, or a nested transition triggered from another state's `before_transition`/`after_transition` — they are deferred until that outermost transaction commits.
243
+ - They never run if the transition fails, if `rollback_transition` is raised, or if a surrounding transaction rolls back after the transition. Multiple transitions inside one transaction each fire their callbacks after commit, in transition order.
244
+
245
+ This makes `after_transition_commit` the safe place to enqueue background jobs, call external APIs, etc.: the work only happens once the new state is actually visible to other database connections.
246
+
247
+ **Note on Rails versions:** deferral to an outer transaction requires Rails 7.2+. On older Rails versions the callbacks fire as soon as the transition itself completes, which may be inside an uncommitted outer transaction.
248
+
249
+ **Footgun: `ActiveRecord.after_all_transactions_commit`** does not work as a substitute inside `before_transition`/`after_transition`. Rails skips non-joinable transactions when deciding whether any transaction is open, and transactional transitions run inside the gem's own non-joinable transaction — so the block yields *immediately*, while the transition is still uncommitted. Use `after_transition_commit` instead.
250
+
235
251
  #### Transient Transition Variables
236
252
 
237
253
  Sometimes you may may want to pass additional arguments to a state transition for additional context in your transition callbacks. To do this, add the `transients` option to the `state_options` declaration. This allows you to define any additional attributes you want to be able to pass along during a state transition to that state.
data/Rakefile CHANGED
@@ -17,11 +17,11 @@ namespace :test do
17
17
  end
18
18
 
19
19
  task :test do
20
- sh "bin/test"
20
+ sh "bin/rspec"
21
21
  end
22
22
 
23
23
  task :lint do
24
- sh "bin/standardrb --no-fix"
24
+ sh "bin/rubocop --no-server"
25
25
  end
26
26
 
27
27
  namespace :changelog do
@@ -10,7 +10,9 @@ class String
10
10
  #
11
11
  # @example
12
12
  # "some random string".transition_to("draft") #=> false
13
- def transition_to(_desired_state)
13
+ # rubocop:disable Naming/PredicateMethod -- mirrors HasStateMachine::State#transition_to API
14
+ def transition_to(_desired_state, **_options)
14
15
  false
15
16
  end
17
+ # rubocop:enable Naming/PredicateMethod
16
18
  end
@@ -16,8 +16,8 @@ module HasStateMachine
16
16
  define_model_callbacks :transition, only: %i[before after]
17
17
 
18
18
  ##
19
- # Defines the after_transition_commit callback. It runs only after a
20
- # transition has committed.
19
+ # Defines the after_transition_commit callback, which runs once a
20
+ # successful transition is committed to the database.
21
21
  define_model_callbacks :transition_commit, only: %i[after]
22
22
 
23
23
  ##
@@ -63,7 +63,7 @@ module HasStateMachine
63
63
  # @return [Boolean] whether or not the transition took place
64
64
  def transition_to(desired_state, **options)
65
65
  transitioned = false
66
- options = options.transform_keys(&:to_sym)
66
+ options = options.symbolize_keys
67
67
  desired_state_instance = state_instance(desired_state, options)
68
68
 
69
69
  with_transition_options(options) do
@@ -78,45 +78,66 @@ module HasStateMachine
78
78
 
79
79
  transitioned
80
80
  ensure
81
- (desired_state_instance&.errors || []).each do |error|
81
+ desired_state_instance&.errors&.each do |error|
82
82
  object.errors.add(error.attribute, error.type)
83
83
  end
84
84
  end
85
85
 
86
86
  ##
87
87
  # Makes the actual transition from one state to the next and
88
- # runs the before and after transition callbacks. The
89
- # after_transition_commit callbacks run after the update completes
90
- # and only when it succeeds.
91
- def perform_transition!
92
- run_callbacks :transition_commit do
93
- run_callbacks :transition do
94
- object.update("#{object.state_attribute}": state)
95
- end
88
+ # runs the before and after transition callbacks.
89
+ #
90
+ # @return [Boolean] whether or not the transition succeeded
91
+ def perform_transition! # rubocop:disable Naming/PredicateMethod -- public API
92
+ transitioned = run_callbacks :transition do
93
+ object.update("#{object.state_attribute}": state)
96
94
  end
95
+ return false unless transitioned
96
+
97
+ @previous_state = previous_state
98
+ enqueue_transition_commit_callbacks
99
+ true
97
100
  end
98
101
 
99
102
  ##
100
- # Makes the actual transition from one state to the next and
101
- # runs the before and after transition callbacks in a transaction
102
- # to allow for roll backs. The after_transition_commit callbacks run
103
- # outside the transaction and only when it commits (not on rollback).
104
- def perform_transactional_transition!
105
- run_callbacks :transition_commit do
106
- ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
107
- run_callbacks :transition do
108
- rollback_transition unless object.update("#{object.state_attribute}": state)
109
- end
103
+ # Same as {#perform_transition!}, but wrapped in a transaction so
104
+ # callbacks can roll the transition back.
105
+ #
106
+ # @return [Boolean] whether or not the transition succeeded
107
+ def perform_transactional_transition! # rubocop:disable Naming/PredicateMethod -- public API
108
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
109
+ run_callbacks :transition do
110
+ rollback_transition unless object.update("#{object.state_attribute}": state)
110
111
  end
112
+ end
111
113
 
112
- @previous_state = previous_state
114
+ @previous_state = previous_state
115
+ return false unless object.reload.public_send(object.state_attribute) == state
113
116
 
114
- object.reload.public_send(object.state_attribute) == state
115
- end
117
+ enqueue_transition_commit_callbacks
118
+ true
116
119
  end
117
120
 
118
121
  private
119
122
 
123
+ ##
124
+ # Runs the after_transition_commit callbacks once the outermost open
125
+ # transaction commits, discarding them on rollback.
126
+ #
127
+ # @note Registers on the connection's current transaction because
128
+ # +ActiveRecord.after_all_transactions_commit+ ignores non-joinable
129
+ # transactions (like the gem's own) and would fire immediately.
130
+ # @return [void]
131
+ def enqueue_transition_commit_callbacks
132
+ current_transaction = object.class.connection.current_transaction
133
+
134
+ # Rails < 7.2 has no Transaction#after_commit, so callbacks fire
135
+ # immediately with no deferral. Drop this guard at Rails 7.2+.
136
+ return run_callbacks(:transition_commit) { true } unless current_transaction.respond_to?(:after_commit)
137
+
138
+ current_transaction.after_commit { run_callbacks(:transition_commit) { true } }
139
+ end
140
+
120
141
  def rollback_transition
121
142
  raise ActiveRecord::Rollback
122
143
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStateMachine
4
- VERSION = "1.2.0"
4
+ VERSION = "1.2.2"
5
5
  end
@@ -12,12 +12,25 @@ module RubyLsp
12
12
  class Addon < ::RubyLsp::Addon
13
13
  def activate(global_state, outgoing_queue)
14
14
  @global_state = global_state
15
+ @model_name_cache = {}
15
16
  @rails_client = register_rails_server_addon(outgoing_queue)
17
+
18
+ rails_status = if @rails_client
19
+ "registered"
20
+ else
21
+ "unavailable, using naming convention only"
22
+ end
23
+
24
+ log(
25
+ outgoing_queue,
26
+ "Activating Has State Machine Ruby LSP add-on v#{version} (Rails integration: #{rails_status})"
27
+ )
16
28
  end
17
29
 
18
30
  def deactivate
19
31
  @global_state = nil
20
32
  @rails_client = nil
33
+ @model_name_cache = nil
21
34
  end
22
35
 
23
36
  def name
@@ -35,7 +48,8 @@ module RubyLsp
35
48
  node_context,
36
49
  dispatcher,
37
50
  index: @global_state&.index,
38
- rails_client: @rails_client
51
+ rails_client: @rails_client,
52
+ model_name_cache: @model_name_cache
39
53
  )
40
54
  end
41
55
 
@@ -53,6 +67,7 @@ module RubyLsp
53
67
 
54
68
  client = rails_addon.rails_runner_client
55
69
  return unless client.respond_to?(:register_server_addon)
70
+ return if client.respond_to?(:connected?) && !client.connected?
56
71
 
57
72
  client.register_server_addon(File.expand_path("rails_server_addon.rb", __dir__))
58
73
  client
@@ -7,25 +7,37 @@ module RubyLsp
7
7
  class Definition
8
8
  SERVER_ADDON_NAME = "has_state_machine"
9
9
 
10
- def initialize(response_builder, _uri, node_context, dispatcher, index: nil, rails_client: nil)
10
+ # AR methods that return self, so `object.reload.foo` targets the model
11
+ # just like `object.foo`.
12
+ SELF_RETURNING_METHODS = ["reload"].freeze
13
+
14
+ def initialize(response_builder, _uri, node_context, dispatcher, index: nil, rails_client: nil,
15
+ model_name_cache: nil)
11
16
  @response_builder = response_builder
12
17
  @node_context = node_context
13
18
  @index = index
14
19
  @rails_client = rails_client
20
+ @model_name_cache = model_name_cache
15
21
 
16
22
  dispatcher.register(self, :on_call_node_enter)
17
23
  end
18
24
 
19
25
  def on_call_node_enter(node)
20
26
  return unless current_target?(node)
21
- return unless resolved_model_name
22
27
 
28
+ # Check the node shape first: resolving the model name may require a
29
+ # round-trip to the Rails runner, so only pay it for `object` calls.
23
30
  if object_call?(node)
31
+ return unless resolved_model_name
32
+
24
33
  push_entries(constant_entries(resolved_model_name))
25
34
  elsif object_method_call?(node)
35
+ return unless resolved_model_name
36
+
26
37
  method_name = message(node)
27
38
  entries = method_entries(resolved_model_name, method_name)
28
39
  entries = association_entries(resolved_model_name, method_name) if entries.empty?
40
+ entries = constant_entries(resolved_model_name) if entries.empty?
29
41
 
30
42
  push_entries(entries)
31
43
  end
@@ -33,7 +45,7 @@ module RubyLsp
33
45
 
34
46
  private
35
47
 
36
- attr_reader :index, :node_context, :rails_client, :response_builder
48
+ attr_reader :index, :model_name_cache, :node_context, :rails_client, :response_builder
37
49
 
38
50
  def current_target?(node)
39
51
  target = node_context.node if node_context.respond_to?(:node)
@@ -77,8 +89,20 @@ module RubyLsp
77
89
  end
78
90
 
79
91
  def model_name_from_rails(workflow_namespace)
80
- return unless workflow_namespace && rails_client
92
+ return unless workflow_namespace && rails_client_available?
93
+
94
+ # Cache hits and misses for the life of the LSP process; the set of
95
+ # models rarely changes mid-session. Failures raise past the cache
96
+ # write, so a slow-to-boot Rails runner can succeed on a later request.
97
+ cache = model_name_cache.to_h
98
+ cache.fetch(workflow_namespace) do
99
+ cache[workflow_namespace] = request_model_name(workflow_namespace)
100
+ end
101
+ rescue
102
+ nil
103
+ end
81
104
 
105
+ def request_model_name(workflow_namespace)
82
106
  result = rails_client.delegate_request(
83
107
  server_addon_name: SERVER_ADDON_NAME,
84
108
  request_name: "model_for_workflow_namespace",
@@ -86,8 +110,6 @@ module RubyLsp
86
110
  )
87
111
 
88
112
  response_name(result)
89
- rescue
90
- nil
91
113
  end
92
114
 
93
115
  def association_entries(model_name, association_name)
@@ -97,8 +119,17 @@ module RubyLsp
97
119
  constant_entries(association_model_name)
98
120
  end
99
121
 
122
+ # Delegating a request to a disconnected client (ruby-lsp-rails NullClient)
123
+ # never produces a response, so read_response would block forever and the
124
+ # convention fallback would never run.
125
+ def rails_client_available?
126
+ return false unless rails_client
127
+
128
+ !rails_client.respond_to?(:connected?) || rails_client.connected?
129
+ end
130
+
100
131
  def association_model_name(model_name, association_name)
101
- return unless rails_client&.respond_to?(:association_target)
132
+ return unless rails_client_available? && rails_client.respond_to?(:association_target)
102
133
 
103
134
  result = rails_client.association_target(model_name: model_name, association_name: association_name)
104
135
  response_name(result)
@@ -154,6 +185,7 @@ module RubyLsp
154
185
 
155
186
  def entry_location(entry)
156
187
  return entry.location if entry.respond_to?(:location)
188
+
157
189
  entry.name_location if entry.respond_to?(:name_location)
158
190
  end
159
191
 
@@ -171,7 +203,14 @@ module RubyLsp
171
203
  end
172
204
 
173
205
  def object_method_call?(node)
174
- message(node) && object_call?(receiver(node))
206
+ message(node) && object_chain?(receiver(node))
207
+ end
208
+
209
+ def object_chain?(node)
210
+ return false unless node
211
+ return true if object_call?(node)
212
+
213
+ SELF_RETURNING_METHODS.include?(message(node)) && object_chain?(receiver(node))
175
214
  end
176
215
 
177
216
  def object_call?(node)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ruby_lsp/ruby_lsp_rails/server"
3
+ require "ruby_lsp/ruby_lsp_rails/server" unless defined?(::RubyLsp::Rails::ServerAddon)
4
4
 
5
5
  module RubyLsp
6
6
  module HasStateMachine
@@ -13,7 +13,7 @@ module RubyLsp
13
13
  with_request_error_handling(request) do
14
14
  case request
15
15
  when "model_for_workflow_namespace"
16
- send_result(model_for_workflow_namespace(params.fetch("workflow_namespace")))
16
+ send_result(model_for_workflow_namespace(params[:workflow_namespace] || params.fetch("workflow_namespace")))
17
17
  else
18
18
  raise NotImplementedError, "Unknown request: #{request}"
19
19
  end
@@ -23,12 +23,24 @@ module RubyLsp
23
23
  private
24
24
 
25
25
  def model_for_workflow_namespace(workflow_namespace)
26
- model = models_by_workflow_namespace[workflow_namespace]
26
+ model = conventional_model_for(workflow_namespace) || models_by_workflow_namespace[workflow_namespace]
27
27
  return unless model
28
28
 
29
29
  {name: model.name}
30
30
  end
31
31
 
32
+ ##
33
+ # Fast path: for the default "Workflow::<Model>" namespace, autoload just
34
+ # that one constant instead of eager loading the whole application. Only
35
+ # custom workflow_namespace configurations need the full scan below.
36
+ def conventional_model_for(workflow_namespace)
37
+ workflow_namespace = workflow_namespace.to_s
38
+ return unless workflow_namespace.start_with?("Workflow::")
39
+
40
+ model = workflow_namespace.delete_prefix("Workflow::").safe_constantize
41
+ model if model.try(:workflow_namespace) == workflow_namespace
42
+ end
43
+
32
44
  def models_by_workflow_namespace
33
45
  @models_by_workflow_namespace ||= active_record_models.each_with_object({}) do |model, index|
34
46
  next unless model.respond_to?(:workflow_namespace)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: has_state_machine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Hargett
@@ -31,9 +31,6 @@ dependencies:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '1.5'
34
- - - "<"
35
- - !ruby/object:Gem::Version
36
- version: '2.0'
37
34
  type: :development
38
35
  prerelease: false
39
36
  version_requirements: !ruby/object:Gem::Requirement
@@ -41,9 +38,6 @@ dependencies:
41
38
  - - ">="
42
39
  - !ruby/object:Gem::Version
43
40
  version: '1.5'
44
- - - "<"
45
- - !ruby/object:Gem::Version
46
- version: '2.0'
47
41
  - !ruby/object:Gem::Dependency
48
42
  name: nokogiri
49
43
  requirement: !ruby/object:Gem::Requirement
@@ -87,7 +81,21 @@ dependencies:
87
81
  - !ruby/object:Gem::Version
88
82
  version: '0'
89
83
  - !ruby/object:Gem::Dependency
90
- name: standard
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.74'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.74'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-performance
91
99
  requirement: !ruby/object:Gem::Requirement
92
100
  requirements:
93
101
  - - ">="
@@ -129,19 +137,19 @@ dependencies:
129
137
  - !ruby/object:Gem::Version
130
138
  version: '0'
131
139
  - !ruby/object:Gem::Dependency
132
- name: minitest
140
+ name: rspec-rails
133
141
  requirement: !ruby/object:Gem::Requirement
134
142
  requirements:
135
- - - "~>"
143
+ - - ">="
136
144
  - !ruby/object:Gem::Version
137
- version: '5.1'
145
+ version: '6.1'
138
146
  type: :development
139
147
  prerelease: false
140
148
  version_requirements: !ruby/object:Gem::Requirement
141
149
  requirements:
142
- - - "~>"
150
+ - - ">="
143
151
  - !ruby/object:Gem::Version
144
- version: '5.1'
152
+ version: '6.1'
145
153
  - !ruby/object:Gem::Dependency
146
154
  name: ruby-lsp
147
155
  requirement: !ruby/object:Gem::Requirement
@@ -170,6 +178,20 @@ dependencies:
170
178
  - - ">="
171
179
  - !ruby/object:Gem::Version
172
180
  version: 0.3.17
181
+ - !ruby/object:Gem::Dependency
182
+ name: ruby-lsp-rspec
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
173
195
  - !ruby/object:Gem::Dependency
174
196
  name: base64
175
197
  requirement: !ruby/object:Gem::Requirement
@@ -268,7 +290,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
268
290
  - !ruby/object:Gem::Version
269
291
  version: '0'
270
292
  requirements: []
271
- rubygems_version: 3.6.7
293
+ rubygems_version: 3.6.9
272
294
  specification_version: 4
273
295
  summary: Class based state machine for ActiveRecord models.
274
296
  test_files: []