state_machines-activerecord 0.8.0 → 0.200.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.md +178 -8
  4. data/lib/state_machines/integrations/active_record/locale.rb +12 -9
  5. data/lib/state_machines/integrations/active_record/type/integer.rb +122 -0
  6. data/lib/state_machines/integrations/active_record/version.rb +3 -1
  7. data/lib/state_machines/integrations/active_record.rb +548 -181
  8. data/lib/state_machines-activerecord.rb +2 -0
  9. metadata +37 -154
  10. data/.gitignore +0 -22
  11. data/.travis.yml +0 -19
  12. data/Appraisals +0 -34
  13. data/Gemfile +0 -6
  14. data/Rakefile +0 -9
  15. data/gemfiles/active_record_5.1.gemfile +0 -14
  16. data/gemfiles/active_record_5.2.gemfile +0 -14
  17. data/gemfiles/active_record_6.0.gemfile +0 -14
  18. data/gemfiles/active_record_6.1.gemfile +0 -14
  19. data/gemfiles/active_record_edge.gemfile +0 -14
  20. data/log/.gitkeep +0 -0
  21. data/state_machines-activerecord.gemspec +0 -28
  22. data/test/files/en.yml +0 -5
  23. data/test/files/models/post.rb +0 -11
  24. data/test/integration_test.rb +0 -25
  25. data/test/machine_by_default_test.rb +0 -16
  26. data/test/machine_errors_test.rb +0 -19
  27. data/test/machine_multiple_test.rb +0 -17
  28. data/test/machine_nested_action_test.rb +0 -38
  29. data/test/machine_unmigrated_test.rb +0 -14
  30. data/test/machine_with_aliased_attribute_test.rb +0 -23
  31. data/test/machine_with_callbacks_test.rb +0 -172
  32. data/test/machine_with_column_state_attribute_test.rb +0 -44
  33. data/test/machine_with_complex_pluralization_scopes_test.rb +0 -16
  34. data/test/machine_with_conflicting_predicate_test.rb +0 -18
  35. data/test/machine_with_conflicting_state_name_test.rb +0 -29
  36. data/test/machine_with_custom_attribute_test.rb +0 -21
  37. data/test/machine_with_default_scope_test.rb +0 -18
  38. data/test/machine_with_different_column_default_test.rb +0 -27
  39. data/test/machine_with_different_integer_column_default_test.rb +0 -29
  40. data/test/machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb +0 -24
  41. data/test/machine_with_dirty_attribute_and_state_events_test.rb +0 -20
  42. data/test/machine_with_dirty_attributes_and_custom_attribute_test.rb +0 -32
  43. data/test/machine_with_dirty_attributes_during_loopback_test.rb +0 -22
  44. data/test/machine_with_dirty_attributes_test.rb +0 -35
  45. data/test/machine_with_dynamic_initial_state_test.rb +0 -99
  46. data/test/machine_with_event_attributes_on_autosave_test.rb +0 -48
  47. data/test/machine_with_event_attributes_on_custom_action_test.rb +0 -41
  48. data/test/machine_with_event_attributes_on_save_bang_test.rb +0 -82
  49. data/test/machine_with_event_attributes_on_save_test.rb +0 -244
  50. data/test/machine_with_event_attributes_on_validation_test.rb +0 -143
  51. data/test/machine_with_events_test.rb +0 -13
  52. data/test/machine_with_failed_action_test.rb +0 -40
  53. data/test/machine_with_failed_after_callbacks_test.rb +0 -35
  54. data/test/machine_with_failed_before_callbacks_test.rb +0 -36
  55. data/test/machine_with_initialized_state_test.rb +0 -41
  56. data/test/machine_with_internationalization_test.rb +0 -180
  57. data/test/machine_with_loopback_test.rb +0 -22
  58. data/test/machine_with_non_column_state_attribute_defined_test.rb +0 -29
  59. data/test/machine_with_same_column_default_test.rb +0 -26
  60. data/test/machine_with_same_integer_column_default_test.rb +0 -30
  61. data/test/machine_with_scopes_and_joins_test.rb +0 -37
  62. data/test/machine_with_scopes_and_owner_subclass_test.rb +0 -27
  63. data/test/machine_with_scopes_test.rb +0 -70
  64. data/test/machine_with_state_driven_validations_test.rb +0 -30
  65. data/test/machine_with_states_test.rb +0 -13
  66. data/test/machine_with_static_initial_state_test.rb +0 -166
  67. data/test/machine_with_transactions_test.rb +0 -26
  68. data/test/machine_with_validations_and_custom_attribute_test.rb +0 -21
  69. data/test/machine_with_validations_test.rb +0 -47
  70. data/test/machine_without_database_test.rb +0 -20
  71. data/test/machine_without_transactions_test.rb +0 -26
  72. data/test/model_test.rb +0 -12
  73. data/test/test_helper.rb +0 -52
@@ -1,9 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'state_machines-activemodel'
2
4
  require 'active_record'
3
5
  require 'state_machines/integrations/active_record/version'
6
+ require 'state_machines/integrations/active_record/type/integer'
4
7
 
5
8
  module StateMachines
6
- module Integrations #:nodoc:
9
+ module Integrations # :nodoc:
7
10
  # Adds support for integrating state machines with ActiveRecord models.
8
11
  #
9
12
  # == Examples
@@ -11,7 +14,7 @@ module StateMachines
11
14
  # Below is an example of a simple state machine defined within an
12
15
  # ActiveRecord model:
13
16
  #
14
- # class Vehicle < ActiveRecord::Base
17
+ # class Vehicle < ApplicationRecord
15
18
  # state_machine :initial => :parked do
16
19
  # event :ignite do
17
20
  # transition :parked => :idling
@@ -78,25 +81,22 @@ module StateMachines
78
81
  # === Security implications
79
82
  #
80
83
  # Beware that public event attributes mean that events can be fired
81
- # whenever mass-assignment is being used. If you want to prevent malicious
82
- # users from tampering with events through URLs / forms, the attribute
83
- # should be protected like so:
84
- #
85
- # class Vehicle < ActiveRecord::Base
86
- # attr_protected :state_event
87
- # # attr_accessible ... # Alternative technique
88
- #
89
- # state_machine do
90
- # ...
84
+ # whenever mass-assignment is being used. If you want to prevent malicious
85
+ # users from tampering with events through URLs / forms, you should use
86
+ # Rails' strong parameters to control which attributes are permitted:
87
+ #
88
+ # class VehiclesController < ApplicationController
89
+ # def vehicle_params
90
+ # params.require(:vehicle).permit(:color, :make, :model)
91
+ # # Exclude state_event to prevent tampering
91
92
  # end
92
93
  # end
93
94
  #
94
95
  # If you want to only have *some* events be able to fire via mass-assignment,
95
96
  # you can build two state machines (one public and one protected) like so:
96
97
  #
97
- # class Vehicle < ActiveRecord::Base
98
- # attr_protected :state_event # Prevent access to events in the first machine
99
- #
98
+ # class Vehicle < ApplicationRecord
99
+ # # Define private machine
100
100
  # state_machine do
101
101
  # # Define private events here
102
102
  # end
@@ -105,6 +105,8 @@ module StateMachines
105
105
  # state_machine :public_state, :attribute => :state do
106
106
  # # Define public events here
107
107
  # end
108
+ #
109
+ # # Control access via strong parameters in your controller
108
110
  # end
109
111
  #
110
112
  # == Transactions
@@ -115,7 +117,7 @@ module StateMachines
115
117
  #
116
118
  # For example,
117
119
  #
118
- # class Message < ActiveRecord::Base
120
+ # class Message < ApplicationRecord
119
121
  # end
120
122
  #
121
123
  # Vehicle.state_machine do
@@ -136,7 +138,7 @@ module StateMachines
136
138
  #
137
139
  # To turn off transactions:
138
140
  #
139
- # class Vehicle < ActiveRecord::Base
141
+ # class Vehicle < ApplicationRecord
140
142
  # state_machine :initial => :parked, :use_transactions => false do
141
143
  # ...
142
144
  # end
@@ -150,7 +152,7 @@ module StateMachines
150
152
  # framework, custom validators will not work as expected when defined to run
151
153
  # in multiple states. For example:
152
154
  #
153
- # class Vehicle < ActiveRecord::Base
155
+ # class Vehicle < ApplicationRecord
154
156
  # state_machine do
155
157
  # ...
156
158
  # state :first_gear, :second_gear do
@@ -163,7 +165,7 @@ module StateMachines
163
165
  # for the <tt>:second_gear</tt> state. To avoid this, you can define your
164
166
  # custom validation like so:
165
167
  #
166
- # class Vehicle < ActiveRecord::Base
168
+ # class Vehicle < ApplicationRecord
167
169
  # state_machine do
168
170
  # ...
169
171
  # state :first_gear, :second_gear do
@@ -195,38 +197,48 @@ module StateMachines
195
197
  # example, assuming there's a validation on a field called +name+ on the class:
196
198
  #
197
199
  # vehicle = Vehicle.new
198
- # vehicle.ignite! # => StateMachines::InvalidTransition: Cannot transition state via :ignite from :parked (Reason(s): Name cannot be blank)
200
+ # vehicle.ignite! # => StateMachines::InvalidTransition: Cannot transition state via :ignite from :parked
201
+ # # (Reason(s): Name cannot be blank)
199
202
  #
200
203
  # == Scopes
201
204
  #
202
- # To assist in filtering models with specific states, a series of named
203
- # scopes are defined on the model for finding records with or without a
205
+ # To assist in filtering models with specific states, a series of scopes
206
+ # are defined on the model for finding records with or without a
204
207
  # particular set of states.
205
208
  #
206
- # These named scopes are essentially the functional equivalent of the
209
+ # These scopes are essentially the functional equivalent of the
207
210
  # following definitions:
208
211
  #
209
- # class Vehicle < ActiveRecord::Base
210
- # named_scope :with_states, lambda {|*states| {:conditions => {:state => states}}}
212
+ # class Vehicle < ApplicationRecord
211
213
  # # with_states also aliased to with_state
214
+ # scope :with_states, ->(states) { states.present? ? where(state: states) : all }
212
215
  #
213
- # named_scope :without_states, lambda {|*states| {:conditions => ['state NOT IN (?)', states]}}
214
216
  # # without_states also aliased to without_state
217
+ # scope :without_states, ->(states) { states.present? ? where.not(state: states) : all }
215
218
  # end
216
219
  #
217
220
  # *Note*, however, that the states are converted to their stored values
218
221
  # before being passed into the query.
219
222
  #
220
- # Because of the way named scopes work in ActiveRecord, they can be
223
+ # Because of the way scopes work in ActiveRecord, they can be
221
224
  # chained like so:
222
225
  #
223
- # Vehicle.with_state(:parked).all(:order => 'id DESC')
226
+ # Vehicle.with_state(:parked).order(id: :desc)
224
227
  #
225
228
  # Note that states can also be referenced by the string version of their
226
229
  # name:
227
230
  #
228
231
  # Vehicle.with_state('parked')
229
232
  #
233
+ # === Transparent Scopes
234
+ #
235
+ # When `nil` is passed to any of the state scopes, they return `all` records
236
+ # without applying any filters. This allows for more flexible scope chaining
237
+ # in search interfaces:
238
+ #
239
+ # Vehicle.with_state(params[:state]) # Returns all vehicles if params[:state] is nil
240
+ # Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles
241
+ #
230
242
  # == Callbacks
231
243
  #
232
244
  # All before/after transition callbacks defined for ActiveRecord models
@@ -235,7 +247,7 @@ module StateMachines
235
247
  #
236
248
  # For example,
237
249
  #
238
- # class Vehicle < ActiveRecord::Base
250
+ # class Vehicle < ApplicationRecord
239
251
  # state_machine :initial => :parked do
240
252
  # before_transition any => :idling do |vehicle|
241
253
  # vehicle.put_on_seatbelt
@@ -266,11 +278,11 @@ module StateMachines
266
278
  # ActiveRecord, a save failure will cause any records that get created in
267
279
  # your callback to roll back. You can work around this issue like so:
268
280
  #
269
- # class TransitionLog < ActiveRecord::Base
270
- # establish_connection Rails.env.to_sym
281
+ # class TransitionLog < ApplicationRecord
282
+ # connects_to database: { writing: :primary, reading: :primary }
271
283
  # end
272
284
  #
273
- # class Vehicle < ActiveRecord::Base
285
+ # class Vehicle < ApplicationRecord
274
286
  # state_machine do
275
287
  # after_failure do |vehicle, transition|
276
288
  # TransitionLog.create(:vehicle => vehicle, :transition => transition)
@@ -280,7 +292,7 @@ module StateMachines
280
292
  # end
281
293
  # end
282
294
  #
283
- # The +TransitionLog+ model establishes a second connection to the database
295
+ # The +TransitionLog+ model establishes a separate connection to the database
284
296
  # that allows new records to be saved without being affected by rollbacks
285
297
  # in the +Vehicle+ model's transaction.
286
298
  #
@@ -304,66 +316,16 @@ module StateMachines
304
316
  # * (8) *after_transition*
305
317
  # * (-) end transaction (if enabled)
306
318
  # * (9) after_commit
319
+ # * (10) *after_transition* callbacks defined with <tt>:after_commit => true</tt>
307
320
  #
308
- # == Observers
309
- #
310
- # In addition to support for ActiveRecord-like hooks, there is additional
311
- # support for ActiveRecord observers. Because of the way ActiveRecord
312
- # observers are designed, there is less flexibility around the specific
313
- # transitions that can be hooked in. However, a large number of hooks
314
- # *are* supported. For example, if a transition for a record's +state+
315
- # attribute changes the state from +parked+ to +idling+ via the +ignite+
316
- # event, the following observer methods are supported:
317
- # * before/after/after_failure_to-_ignite_from_parked_to_idling
318
- # * before/after/after_failure_to-_ignite_from_parked
319
- # * before/after/after_failure_to-_ignite_to_idling
320
- # * before/after/after_failure_to-_ignite
321
- # * before/after/after_failure_to-_transition_state_from_parked_to_idling
322
- # * before/after/after_failure_to-_transition_state_from_parked
323
- # * before/after/after_failure_to-_transition_state_to_idling
324
- # * before/after/after_failure_to-_transition_state
325
- # * before/after/after_failure_to-_transition
326
- #
327
- # The following class shows an example of some of these hooks:
328
- #
329
- # class VehicleObserver < ActiveRecord::Observer
330
- # def before_save(vehicle)
331
- # # log message
332
- # end
333
- #
334
- # # Callback for :ignite event *before* the transition is performed
335
- # def before_ignite(vehicle, transition)
336
- # # log message
337
- # end
338
- #
339
- # # Callback for :ignite event *after* the transition has been performed
340
- # def after_ignite(vehicle, transition)
341
- # # put on seatbelt
342
- # end
343
- #
344
- # # Generic transition callback *before* the transition is performed
345
- # def after_transition(vehicle, transition)
346
- # Audit.log(vehicle, transition)
347
- # end
348
- # end
349
- #
350
- # More flexible transition callbacks can be defined directly within the
351
- # model as described in StateMachines::Machine#before_transition
352
- # and StateMachines::Machine#after_transition.
353
- #
354
- # To define a single observer for multiple state machines:
355
- #
356
- # class StateMachineObserver < ActiveRecord::Observer
357
- # observe Vehicle, Switch, Project
358
- #
359
- # def after_transition(record, transition)
360
- # Audit.log(record, transition)
361
- # end
362
- # end
321
+ # An +after_transition+ callback defined with the <tt>:after_commit</tt>
322
+ # option is deferred until the surrounding database transaction has been
323
+ # committed (and discarded if it rolls back). See the documentation for
324
+ # +after_transition+ in this integration for more details.
363
325
  #
364
326
  # == Internationalization
365
327
  #
366
- # In Rails 2.2+, any error message that is generated from performing invalid
328
+ # Any error message that is generated from performing invalid
367
329
  # transitions can be localized. The following default translations are used:
368
330
  #
369
331
  # en:
@@ -376,9 +338,6 @@ module StateMachines
376
338
  # # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name
377
339
  # invalid_transition: "cannot transition via %{event}"
378
340
  #
379
- # Notice that the interpolation syntax is %{key} in Rails 3+. In Rails 2.x,
380
- # the appropriate syntax is {{key}}.
381
- #
382
341
  # You can override these for a specific model like so:
383
342
  #
384
343
  # en:
@@ -418,8 +377,441 @@ module StateMachines
418
377
  include ActiveModel
419
378
 
420
379
  # The default options to use for state machines using this integration
421
- @defaults = {:action => :save, use_transactions: true}
380
+ @defaults = { action: :save, use_transactions: true }
381
+ @auto_convert_integer_state_attributes = true
382
+
383
+ # Machine-specific methods for enum integration
384
+ module MachineMethods
385
+ # Enum integration metadata storage
386
+ attr_accessor :enum_integration
387
+
388
+ # Hook called after machine initialization
389
+ def after_initialize
390
+ super
391
+ initialize_enum_integration
392
+
393
+ register_integer_type if register_integer_type?
394
+ end
395
+
396
+ # Hook called when a machine is assigned to a class, including when an
397
+ # inherited machine is cloned for an STI subclass. The cloned machine
398
+ # carries the parent's @integer_type_registered flag while the subclass
399
+ # still inherits the parent's attribute type, which references the
400
+ # parent machine's state collection. Re-register so the subclass type
401
+ # sees this machine's (cloned) states, picking up subclass-added states
402
+ # as they are defined.
403
+ #
404
+ # @param klass [Class] the new owner class
405
+ # @return [Class] the assigned owner class
406
+ def owner_class=(klass)
407
+ super.tap do
408
+ register_integer_type if integer_type_registered?
409
+ end
410
+ end
411
+
412
+ # Check if enum integration should be enabled for this machine
413
+ def detect_enum_integration
414
+ return nil unless owner_class.defined_enums.key?(attribute.to_s)
415
+
416
+ # For now, auto-detect enum and enable basic integration
417
+ # Later we can add explicit configuration options
418
+ {
419
+ enabled: true,
420
+ prefix: true,
421
+ suffix: false,
422
+ scopes: true,
423
+ enum_values: owner_class.defined_enums[attribute.to_s] || {},
424
+ original_enum_methods: detect_existing_enum_methods,
425
+ state_machine_methods: []
426
+ }
427
+ end
428
+
429
+ # Initialize enum integration if enum is detected
430
+ def initialize_enum_integration
431
+ detected_config = detect_enum_integration
432
+ return unless detected_config
433
+
434
+ # Store enum integration metadata
435
+ self.enum_integration = detected_config
436
+ end
437
+
438
+ # Override state method to trigger method generation after states are defined
439
+ def state(*, &)
440
+ result = super
441
+
442
+ # Generate methods after each state addition if enum integration is enabled
443
+ generate_state_machine_methods if enum_integrated?
444
+
445
+ # States defined after initialization (e.g. adding an explicit value
446
+ # to the initial state) can change how the column default maps to
447
+ # states, so re-evaluate the conflicting-default warning
448
+ recheck_conflicting_attribute_default if integer_type_registered?
449
+
450
+ result
451
+ end
452
+
453
+ # Warns at most once when the column default conflicts with the
454
+ # machine's initial state. The conflict is re-evaluated as states are
455
+ # defined, so remember when the warning has been issued.
456
+ def check_conflicting_attribute_default
457
+ return if @attribute_default_conflict_warned
458
+
459
+ initial_state = states.detect(&:initial)
460
+ conflict = !owner_class_attribute_default.nil? && (
461
+ dynamic_initial_state? || !owner_class_attribute_default_matches?(initial_state)
462
+ )
463
+ return unless conflict
464
+
465
+ @attribute_default_conflict_warned = true
466
+ super
467
+ end
468
+
469
+ # Returns true when this machine should use the custom integer attribute type
470
+ # to convert between Ruby state names and integer database values. This only
471
+ # applies to non-enum integer columns when automatic conversion is enabled.
472
+ def register_integer_type?
473
+ StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes &&
474
+ integer_column? &&
475
+ !enum_integrated?
476
+ end
477
+
478
+ # Check if this machine has enum integration enabled
479
+ def enum_integrated?
480
+ enum_integration && enum_integration[:enabled]
481
+ end
482
+
483
+ # Get the enum mapping for this attribute
484
+ def enum_mapping
485
+ return {} unless enum_integrated?
486
+
487
+ enum_integration[:enum_values] || {}
488
+ end
489
+
490
+ # Get list of original enum methods that were preserved
491
+ def original_enum_methods
492
+ return [] unless enum_integrated?
493
+
494
+ enum_integration[:original_enum_methods] || []
495
+ end
496
+
497
+ # Get list of state machine methods that were generated
498
+ def state_machine_methods
499
+ return [] unless enum_integrated?
500
+
501
+ enum_integration[:state_machine_methods] || []
502
+ end
503
+
504
+ def integer_type_registered?
505
+ !!@integer_type_registered
506
+ end
507
+
508
+ # Creates a callback that will be invoked *after* a transition is
509
+ # performed, so long as the given requirements match the transition.
510
+ #
511
+ # In addition to the configuration options supported by the core
512
+ # +after_transition+ (see StateMachines::Machine#after_transition), the
513
+ # ActiveRecord integration supports:
514
+ # * <tt>:after_commit</tt> - Defer execution of the callback until the
515
+ # database transaction wrapping the transition has been committed.
516
+ # When no transaction is open at that point, the callback runs
517
+ # immediately. When the transaction (or an outer transaction wrapping
518
+ # it) is rolled back, the callback is discarded.
519
+ #
520
+ # This is the safe place to enqueue background jobs that reference the
521
+ # record (e.g. via GlobalID), since a regular +after_transition+ runs
522
+ # inside the transaction, before the record's changes are visible to
523
+ # other connections:
524
+ #
525
+ # class Vehicle < ApplicationRecord
526
+ # state_machine do
527
+ # after_transition on: :ignite, after_commit: true do |vehicle|
528
+ # EngineWarmupJob.perform_later(vehicle)
529
+ # end
530
+ #
531
+ # ...
532
+ # end
533
+ # end
534
+ #
535
+ # Note that a deferred callback cannot halt the callback chain or
536
+ # affect the result of the transition: by the time it runs, the
537
+ # transition has already been committed. For the same reason, an
538
+ # exception raised by a deferred callback is not propagated (doing so
539
+ # would revert the record's in-memory state even though the database
540
+ # was already updated); it is reported to +ActiveSupport.error_reporter+
541
+ # (+Rails.error+) instead. Conditions (<tt>:if</tt>/<tt>:unless</tt>)
542
+ # and state requirements are evaluated when the transition is
543
+ # performed, not at commit time.
544
+ #
545
+ # Like ActiveRecord's own +after_commit+, a surrounding
546
+ # <tt>transaction(joinable: false)</tt> wrapper is transparent: the
547
+ # callback fires at the inner commit. This is what makes it fire
548
+ # under transactional test fixtures.
549
+ def after_transition(*args, **options, &block)
550
+ # The flag may hide in a legacy trailing positional options hash
551
+ positional_options = args.last.is_a?(Hash) ? args.pop.dup : {}
552
+ options = positional_options.merge(options)
553
+
554
+ # Only a boolean is the flag — a non-boolean value is the implicit
555
+ # state-requirement form for a state named :after_commit
556
+ flag = options[:after_commit]
557
+ return super unless flag == true || flag == false
558
+
559
+ options.delete(:after_commit)
560
+ return super unless flag
561
+
562
+ # Method handling goes to a real Callback (reusing core's binding,
563
+ # arity and :do semantics); branch matching stays on the wrapper so
564
+ # conditions are evaluated at transition time
565
+ parsed = parse_callback_arguments(args, options)
566
+ method_options = parsed.slice(:do, :bind_to_object)
567
+ method_options[:terminator] = callback_terminator
568
+ branch_options = parsed.except(:do, :bind_to_object, :terminator)
569
+
570
+ deferred = Callback.new(:after, method_options, &block)
571
+
572
+ super(**branch_options, bind_to_object: false) do |object, transition|
573
+ object.class.current_transaction.after_commit do
574
+ # The transition's catch(:halt) is gone at commit time
575
+ catch(:halt) { deferred.call(object, {}, transition) }
576
+ rescue StandardError => e
577
+ # Raising would roll back in-memory state already committed; report instead
578
+ ActiveSupport.error_reporter.report(e, handled: false, source: 'state_machines-activerecord')
579
+ end
580
+ end
581
+ end
582
+
583
+ # Machine internals (state matching, validations) call read() to get the
584
+ # current state value and compare it against state.value. The custom
585
+ # integer type already returns the canonical value when state values are
586
+ # uniform: raw integers when every named state has an explicit integer
587
+ # value (passthrough), name strings when none do (state.value is the
588
+ # name). Only machines mixing explicit and auto-indexed values need an
589
+ # override, because the type returns name strings while the explicit
590
+ # states match on integers; map the stored value back to the matched
591
+ # state's canonical state.value.
592
+ #
593
+ # @param object [ActiveRecord::Base] record being read
594
+ # @param attr_sym [Symbol] attribute kind (:state, :event, ...)
595
+ # @param ivar [Boolean] whether to read from an instance variable
596
+ # @return [Object] a value machine internals can match on state.value
597
+ def read(object, attr_sym, ivar = false)
598
+ return super unless integer_type_registered? && attr_sym == :state
599
+ return super unless mixed_integer_state_values?
600
+
601
+ raw = object.read_attribute_before_type_cast(attribute.to_s)
602
+ if raw.is_a?(::String) || raw.is_a?(::Symbol)
603
+ matched = states.detect { |s| s.name && s.name.to_s == raw.to_s }
604
+ return matched.value if matched
605
+ end
606
+
607
+ name = owner_class.type_for_attribute(attribute.to_s).deserialize(raw)
608
+ matched = states.detect { |s| s.name && s.name.to_s == name.to_s }
609
+ matched ? matched.value : raw
610
+ end
611
+
612
+ private
613
+
614
+ # Re-runs the conflicting-default check for states defined after
615
+ # initialization. Skipped until an initial state exists (the DSL block
616
+ # evaluates before initial_state= during Machine.new).
617
+ #
618
+ # @return [void]
619
+ def recheck_conflicting_attribute_default
620
+ return unless states.detect(&:initial)
621
+
622
+ check_conflicting_attribute_default
623
+ end
624
+
625
+ # Returns true when named states mix explicit integer values with
626
+ # auto-indexed (name-valued) states. Uses value(false) so dynamic
627
+ # (Proc) state values are never evaluated for this metadata decision.
628
+ #
629
+ # @return [Boolean]
630
+ def mixed_integer_state_values?
631
+ named = states.select(&:name)
632
+ explicit = named.count { |s| s.value(false).is_a?(::Integer) }
633
+ explicit.positive? && explicit < named.size
634
+ end
635
+
636
+ # Returns true when the state machine attribute is backed by an integer column
637
+ def integer_column?
638
+ return false unless owner_class.respond_to?(:type_for_attribute)
639
+ return false unless owner_class.connected? && owner_class.table_exists?
640
+
641
+ owner_class.type_for_attribute(attribute.to_s).type == :integer
642
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionNotEstablished
643
+ false
644
+ end
645
+
646
+ # Registers a custom AR attribute type so that integer columns transparently
647
+ # convert between state name strings and stored integers.
648
+ # Saves the raw column default first so the conflicting-default check
649
+ # (which fires later, during initial_state=) still compares raw integers.
650
+ def register_integer_type
651
+ @raw_integer_column_default = owner_class.column_defaults[attribute.to_s]
652
+ @integer_type_registered = true
653
+
654
+ # When re-registering (e.g. for an STI subclass that inherited the
655
+ # parent's custom type), unwrap it to keep the column's original type
656
+ # as the passthrough delegate.
657
+ current_type = owner_class.type_for_attribute(attribute.to_s)
658
+ raw_type = current_type.is_a?(StateMachines::Type::Integer) ? current_type.raw_type : current_type
659
+
660
+ owner_class.attribute(attribute.to_s, StateMachines::Type::Integer.new(states, raw_type: raw_type))
661
+ end
662
+
663
+ # Detect existing enum methods for this attribute
664
+ def detect_existing_enum_methods
665
+ return [] unless owner_class.defined_enums.key?(attribute.to_s)
666
+
667
+ enum_values = owner_class.defined_enums[attribute.to_s]
668
+ methods = []
669
+
670
+ enum_values.each_key do |value|
671
+ # Predicate methods like 'active?'
672
+ predicate = "#{value}?"
673
+ methods << predicate if owner_class.method_defined?(predicate)
674
+
675
+ # Bang methods like 'active!'
676
+ bang_method = "#{value}!"
677
+ methods << bang_method if owner_class.method_defined?(bang_method)
678
+
679
+ # Scope methods (class-level)
680
+ methods << value.to_s if owner_class.respond_to?(value)
681
+ methods << "not_#{value}" if owner_class.respond_to?("not_#{value}")
682
+ end
683
+
684
+ methods
685
+ end
686
+
687
+ # Generate method name with prefix/suffix based on configuration
688
+ def generate_state_method_name(state_name, method_type)
689
+ return state_name unless enum_integrated?
690
+
691
+ config = enum_integration
692
+ base_name = case method_type
693
+ when :predicate
694
+ "#{state_name}?"
695
+ when :bang
696
+ "#{state_name}!"
697
+ else
698
+ state_name.to_s
699
+ end
700
+
701
+ # Apply prefix
702
+ if config[:prefix]
703
+ prefix = config[:prefix] == true ? "#{attribute}_" : "#{config[:prefix]}_"
704
+ base_name = "#{prefix}#{base_name}"
705
+ end
706
+
707
+ # Apply suffix
708
+ if config[:suffix]
709
+ suffix = config[:suffix] == true ? "_#{attribute}" : "_#{config[:suffix]}"
710
+ base_name = base_name.gsub(/(\?|!)$/, "#{suffix}\\1")
711
+ base_name = "#{base_name}#{suffix}" unless base_name.end_with?('?', '!')
712
+ end
713
+
714
+ base_name
715
+ end
716
+
717
+ # Generate state machine methods with conflict resolution
718
+ def generate_state_machine_methods
719
+ return unless enum_integrated?
720
+
721
+ # Initialize tracking if not already done
722
+ @processed_states ||= Set.new
723
+ enum_integration[:state_machine_methods] ||= []
724
+
725
+ # Get all states for this machine
726
+ states.each do |state|
727
+ state_name = state.name.to_s
728
+ next if state.nil? # Skip nil state
729
+ next if @processed_states.include?(state_name) # Skip already processed states
730
+
731
+ # Generate predicate method (e.g., status_pending?)
732
+ predicate_method = generate_state_method_name(state_name, :predicate)
733
+ if predicate_method != "#{state_name}?"
734
+ define_state_predicate_method(state_name, predicate_method)
735
+ track_generated_method(predicate_method)
736
+ end
737
+
738
+ # Generate bang method (e.g., status_pending!)
739
+ bang_method = generate_state_method_name(state_name, :bang)
740
+ if bang_method != "#{state_name}!"
741
+ define_state_bang_method(state_name, bang_method)
742
+ track_generated_method(bang_method)
743
+ end
744
+
745
+ # Generate scope methods (e.g., status_pending) if scopes are enabled
746
+ if enum_integration[:scopes]
747
+ scope_method = generate_state_method_name(state_name, :scope)
748
+ if scope_method != state_name
749
+ define_state_scope_method(state_name, scope_method)
750
+ track_generated_method(scope_method)
751
+ end
752
+ end
753
+
754
+ # Mark this state as processed
755
+ @processed_states.add(state_name)
756
+ end
757
+ end
758
+
759
+ # Define a prefixed predicate method for a state
760
+ def define_state_predicate_method(state_name, method_name)
761
+ machine_attribute = attribute
762
+ target_state_name = state_name.to_sym
763
+ owner_class.define_method(method_name) do
764
+ machine = self.class.state_machine(machine_attribute)
765
+ machine.states.matches?(self, target_state_name)
766
+ end
767
+ end
768
+
769
+ # Define a prefixed bang method for a state
770
+ def define_state_bang_method(state_name, method_name)
771
+ owner_class.define_method(method_name) do
772
+ # Raise an error with actionable guidance
773
+ raise "#{method_name} is a conflict-resolution placeholder. " \
774
+ "Use the original enum method '#{state_name}!' or state machine events instead."
775
+ end
776
+ end
777
+
778
+ # Define a prefixed scope method for a state
779
+ def define_state_scope_method(state_name, method_name)
780
+ machine_attribute = attribute
781
+ scope_lambda = lambda do |value = true|
782
+ machine = state_machine(machine_attribute)
783
+ state_value = machine.states[state_name.to_sym].value
784
+ if value
785
+ where(machine_attribute => state_value)
786
+ else
787
+ where.not(machine_attribute => state_value)
788
+ end
789
+ end
790
+
791
+ owner_class.define_singleton_method(method_name, &scope_lambda)
792
+ owner_class.define_singleton_method("not_#{method_name}") do
793
+ public_send(method_name, false)
794
+ end
795
+ end
796
+
797
+ # Track generated state machine methods for introspection
798
+ def track_generated_method(method_name)
799
+ return unless enum_integrated?
800
+
801
+ # Use a Set to ensure no duplicates
802
+ enum_integration[:state_machine_methods] ||= []
803
+ return if enum_integration[:state_machine_methods].include?(method_name)
804
+
805
+ enum_integration[:state_machine_methods] << method_name
806
+ end
807
+ end
808
+
809
+ # Include MachineMethods to make enum integration methods available on machine instances
810
+ include MachineMethods
811
+
422
812
  class << self
813
+ attr_accessor :auto_convert_integer_state_attributes
814
+
423
815
  # Classes that inherit from ActiveRecord::Base will automatically use
424
816
  # the ActiveRecord integration.
425
817
  def matching_ancestors
@@ -434,79 +826,50 @@ module StateMachines
434
826
  action == :save
435
827
  end
436
828
 
437
- # Gets the db default for the machine's attribute
438
- if ::ActiveRecord.gem_version >= Gem::Version.new('4.2.0')
439
- def owner_class_attribute_default
440
- if owner_class.connected? && owner_class.table_exists?
441
- owner_class.column_defaults[attribute.to_s]
442
- end
443
- end
444
- else
445
- def owner_class_attribute_default
446
- if owner_class.connected? && owner_class.table_exists?
447
- if column = owner_class.columns_hash[attribute.to_s]
448
- column.default
449
- end
450
- end
451
- end
829
+ # Gets the db default for the machine's attribute.
830
+ # For integer columns the raw pre-type-registration default is returned so
831
+ # that check_conflicting_attribute_default can compare integers to integers.
832
+ def owner_class_attribute_default
833
+ return @raw_integer_column_default if defined?(@raw_integer_column_default)
834
+ return unless owner_class.connected? && owner_class.table_exists?
835
+
836
+ owner_class.column_defaults[attribute.to_s]
452
837
  end
453
838
 
454
- def define_state_initializer
455
- if ::ActiveRecord.gem_version >= Gem::Version.new('5.0.0.alpha')
456
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
457
- def initialize(attributes = nil, *)
458
- super(attributes) do |*args|
459
- scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
460
-
461
- self.class.state_machines.initialize_states(self, {}, scoped_attributes)
462
- yield(*args) if block_given?
463
- end
464
- end
465
- end_eval
466
- elsif ::ActiveRecord.gem_version >= Gem::Version.new('4.2')
467
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
468
- def initialize(attributes = nil, options = {})
469
- scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
470
-
471
- super(attributes, options) do |*args|
472
- self.class.state_machines.initialize_states(self, {}, scoped_attributes)
473
- yield(*args) if block_given?
474
- end
475
- end
476
- end_eval
477
- else
478
- # Initializes static states
479
- #
480
- # This is the only available hook where the default set of attributes
481
- # can be overridden for a new object *prior* to the processing of the
482
- # attributes passed into #initialize
483
- define_helper :class, <<-end_eval, __FILE__, __LINE__ + 1
484
- def column_defaults(*) #:nodoc:
485
- result = super
486
- # No need to pass in an object, since the overrides will be forced
487
- self.state_machines.initialize_states(nil, :static => :force, :dynamic => false, :to => result)
488
- result
489
- end
490
- end_eval
839
+ # Checks whether the given state matches the column default. When the
840
+ # custom integer type is registered, auto-indexed states match on their
841
+ # name string while the column default is a raw integer, so the default
842
+ # is also compared through the type's mapping (e.g. 0 matches the first
843
+ # auto-indexed state).
844
+ #
845
+ # @param state [StateMachines::State] the state to compare (the initial state)
846
+ # @return [Boolean] whether the column default represents this state
847
+ def owner_class_attribute_default_matches?(state)
848
+ matches = super
849
+ return matches if matches || !integer_type_registered?
491
850
 
492
- # Initializes dynamic states
493
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
494
- def initialize(attributes = nil, options = {})
495
- scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
851
+ default = owner_class_attribute_default
852
+ state.matches?(owner_class.type_for_attribute(attribute.to_s).deserialize(default))
853
+ end
496
854
 
497
- super(attributes, options) do |*args|
498
- self.class.state_machines.initialize_states(self, {}, scoped_attributes)
499
- yield(*args) if block_given?
500
- end
855
+ def define_state_initializer
856
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
857
+ def initialize(attributes = nil, *)
858
+ super(attributes) do |*args|
859
+ attributes = (attributes || {}).transform_keys { |key| self.class.attribute_aliases[key.to_s] || key }
860
+ scoped_attributes = attributes.merge(self.class.scope_attributes)
861
+
862
+ self.class.state_machines.initialize_states(self, {}, scoped_attributes)
863
+ yield(*args) if block_given?
501
864
  end
502
- end_eval
503
- end
865
+ end
866
+ END_EVAL
504
867
  end
505
868
 
506
869
  # Uses around callbacks to run state events if using the :save hook
507
870
  def define_action_hook
508
871
  if action_hook == :save
509
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
872
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
510
873
  def save(*, **)
511
874
  self.class.state_machine(#{name.inspect}).send(:around_save, self) { super }
512
875
  end
@@ -519,33 +882,42 @@ module StateMachines
519
882
  def changed_for_autosave?
520
883
  super || self.class.state_machines.any? {|name, machine| machine.action == :save && machine.read(self, :event)}
521
884
  end
522
- end_eval
885
+ END_EVAL
523
886
  else
524
887
  super
525
888
  end
526
889
  end
527
890
 
528
891
  # Runs state events around the machine's :save action
529
- def around_save(object)
530
- object.class.state_machines.transitions(object, action).perform { yield }
892
+ def around_save(object, &)
893
+ # Pass fiber: false to avoid deadlocks with ActiveRecord's LoadInterlockAwareMonitor
894
+ object.class.state_machines.transitions(object, action, fiber: false).perform(&)
531
895
  end
532
896
 
533
897
  # Creates a scope for finding records *with* a particular state or
534
898
  # states for the attribute
535
- def create_with_scope(name)
536
- create_scope(name, ->(values) { ["#{attribute_column} IN (?)", values] })
899
+ def create_with_scope(_name)
900
+ attr_name = attribute
901
+ lambda do |klass, values|
902
+ if values.present?
903
+ klass.where(attr_name => values)
904
+ else
905
+ klass.all
906
+ end
907
+ end
537
908
  end
538
909
 
539
910
  # Creates a scope for finding records *without* a particular state or
540
911
  # states for the attribute
541
- def create_without_scope(name)
542
- create_scope(name, ->(values) { ["#{attribute_column} NOT IN (?)", values] })
543
- end
544
-
545
- # Generates the fully-qualifed column name for this machine's attribute
546
- def attribute_column
547
- connection = owner_class.connection
548
- "#{connection.quote_table_name(owner_class.table_name)}.#{connection.quote_column_name(attribute)}"
912
+ def create_without_scope(_name)
913
+ attr_name = attribute
914
+ lambda do |klass, values|
915
+ if values.present?
916
+ klass.where.not(attr_name => values)
917
+ else
918
+ klass.all
919
+ end
920
+ end
549
921
  end
550
922
 
551
923
  # Runs a new database transaction, rolling back any changes by raising
@@ -554,7 +926,7 @@ module StateMachines
554
926
  def transaction(object)
555
927
  result = nil
556
928
  object.class.transaction do
557
- raise ::ActiveRecord::Rollback unless result = yield
929
+ raise ::ActiveRecord::Rollback unless (result = yield)
558
930
  end
559
931
  result
560
932
  end
@@ -565,17 +937,12 @@ module StateMachines
565
937
 
566
938
  private
567
939
 
568
- # Defines a new named scope with the given name
569
- def create_scope(name, scope)
570
- lambda { |model, values| model.where(scope.call(values)) }
571
- end
572
-
573
- # ActiveModel's use of method_missing / respond_to for attribute methods
574
- # breaks both ancestor lookups and defined?(super). Need to special-case
575
- # the existence of query attribute methods.
576
- def owner_class_ancestor_has_method?(scope, method)
577
- scope == :instance && method == "#{attribute}?" ? owner_class : super
578
- end
940
+ # ActiveModel's use of method_missing / respond_to for attribute methods
941
+ # breaks both ancestor lookups and defined?(super). Need to special-case
942
+ # the existence of query attribute methods.
943
+ def owner_class_ancestor_has_method?(scope, method)
944
+ scope == :instance && method == "#{attribute}?" ? owner_class : super
945
+ end
579
946
  end
580
947
  register(ActiveRecord)
581
948
  end