state_machines-activerecord 0.10.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +173 -2
  3. data/lib/state_machines/integrations/active_record/locale.rb +10 -9
  4. data/lib/state_machines/integrations/active_record/type/integer.rb +122 -0
  5. data/lib/state_machines/integrations/active_record/version.rb +1 -1
  6. data/lib/state_machines/integrations/active_record.rb +514 -38
  7. metadata +36 -138
  8. data/test/files/en.yml +0 -5
  9. data/test/files/models/post.rb +0 -13
  10. data/test/integration_test.rb +0 -27
  11. data/test/machine_by_default_test.rb +0 -18
  12. data/test/machine_errors_test.rb +0 -21
  13. data/test/machine_multiple_test.rb +0 -19
  14. data/test/machine_nested_action_test.rb +0 -40
  15. data/test/machine_unmigrated_test.rb +0 -16
  16. data/test/machine_with_aliased_attribute_test.rb +0 -25
  17. data/test/machine_with_callbacks_test.rb +0 -174
  18. data/test/machine_with_column_state_attribute_test.rb +0 -46
  19. data/test/machine_with_complex_pluralization_scopes_test.rb +0 -18
  20. data/test/machine_with_conflicting_predicate_test.rb +0 -20
  21. data/test/machine_with_conflicting_state_name_test.rb +0 -31
  22. data/test/machine_with_custom_attribute_test.rb +0 -23
  23. data/test/machine_with_default_scope_test.rb +0 -20
  24. data/test/machine_with_different_column_default_test.rb +0 -29
  25. data/test/machine_with_different_integer_column_default_test.rb +0 -31
  26. data/test/machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb +0 -26
  27. data/test/machine_with_dirty_attribute_and_state_events_test.rb +0 -22
  28. data/test/machine_with_dirty_attributes_and_custom_attribute_test.rb +0 -34
  29. data/test/machine_with_dirty_attributes_during_loopback_test.rb +0 -24
  30. data/test/machine_with_dirty_attributes_test.rb +0 -37
  31. data/test/machine_with_dynamic_initial_state_test.rb +0 -101
  32. data/test/machine_with_event_attributes_on_autosave_test.rb +0 -50
  33. data/test/machine_with_event_attributes_on_custom_action_test.rb +0 -43
  34. data/test/machine_with_event_attributes_on_save_bang_test.rb +0 -84
  35. data/test/machine_with_event_attributes_on_save_test.rb +0 -246
  36. data/test/machine_with_event_attributes_on_validation_test.rb +0 -145
  37. data/test/machine_with_events_test.rb +0 -15
  38. data/test/machine_with_failed_action_test.rb +0 -42
  39. data/test/machine_with_failed_after_callbacks_test.rb +0 -37
  40. data/test/machine_with_failed_before_callbacks_test.rb +0 -38
  41. data/test/machine_with_initialized_state_test.rb +0 -43
  42. data/test/machine_with_internationalization_test.rb +0 -182
  43. data/test/machine_with_loopback_test.rb +0 -24
  44. data/test/machine_with_non_column_state_attribute_defined_test.rb +0 -31
  45. data/test/machine_with_same_column_default_test.rb +0 -28
  46. data/test/machine_with_same_integer_column_default_test.rb +0 -32
  47. data/test/machine_with_scopes_and_joins_test.rb +0 -40
  48. data/test/machine_with_scopes_and_owner_subclass_test.rb +0 -29
  49. data/test/machine_with_scopes_test.rb +0 -72
  50. data/test/machine_with_state_driven_validations_test.rb +0 -32
  51. data/test/machine_with_states_test.rb +0 -15
  52. data/test/machine_with_static_initial_state_test.rb +0 -169
  53. data/test/machine_with_transactions_test.rb +0 -28
  54. data/test/machine_with_validations_and_custom_attribute_test.rb +0 -23
  55. data/test/machine_with_validations_test.rb +0 -49
  56. data/test/machine_without_database_test.rb +0 -22
  57. data/test/machine_without_transactions_test.rb +0 -28
  58. data/test/model_test.rb +0 -14
  59. data/test/test_helper.rb +0 -57
@@ -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
@@ -194,7 +197,8 @@ module StateMachines
194
197
  # example, assuming there's a validation on a field called +name+ on the class:
195
198
  #
196
199
  # vehicle = Vehicle.new
197
- # 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)
198
202
  #
199
203
  # == Scopes
200
204
  #
@@ -207,10 +211,10 @@ module StateMachines
207
211
  #
208
212
  # class Vehicle < ApplicationRecord
209
213
  # # with_states also aliased to with_state
210
- # scope :with_states, ->(states) { where(state: states) }
214
+ # scope :with_states, ->(states) { states.present? ? where(state: states) : all }
211
215
  #
212
216
  # # without_states also aliased to without_state
213
- # scope :without_states, ->(states) { where.not(state: states) }
217
+ # scope :without_states, ->(states) { states.present? ? where.not(state: states) : all }
214
218
  # end
215
219
  #
216
220
  # *Note*, however, that the states are converted to their stored values
@@ -226,6 +230,15 @@ module StateMachines
226
230
  #
227
231
  # Vehicle.with_state('parked')
228
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
+ #
229
242
  # == Callbacks
230
243
  #
231
244
  # All before/after transition callbacks defined for ActiveRecord models
@@ -303,6 +316,12 @@ module StateMachines
303
316
  # * (8) *after_transition*
304
317
  # * (-) end transaction (if enabled)
305
318
  # * (9) after_commit
319
+ # * (10) *after_transition* callbacks defined with <tt>:after_commit => true</tt>
320
+ #
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.
306
325
  #
307
326
  # == Internationalization
308
327
  #
@@ -358,8 +377,441 @@ module StateMachines
358
377
  include ActiveModel
359
378
 
360
379
  # The default options to use for state machines using this integration
361
- @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
+
362
812
  class << self
813
+ attr_accessor :auto_convert_integer_state_attributes
814
+
363
815
  # Classes that inherit from ActiveRecord::Base will automatically use
364
816
  # the ActiveRecord integration.
365
817
  def matching_ancestors
@@ -374,30 +826,50 @@ module StateMachines
374
826
  action == :save
375
827
  end
376
828
 
377
- # Gets the db default for the machine's attribute
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.
378
832
  def owner_class_attribute_default
379
- if owner_class.connected? && owner_class.table_exists?
380
- owner_class.column_defaults[attribute.to_s]
381
- end
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]
837
+ end
838
+
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?
850
+
851
+ default = owner_class_attribute_default
852
+ state.matches?(owner_class.type_for_attribute(attribute.to_s).deserialize(default))
382
853
  end
383
854
 
384
855
  def define_state_initializer
385
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
856
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
386
857
  def initialize(attributes = nil, *)
387
858
  super(attributes) do |*args|
388
- scoped_attributes = (attributes || {}).merge(self.class.scope_attributes)
859
+ attributes = (attributes || {}).transform_keys { |key| self.class.attribute_aliases[key.to_s] || key }
860
+ scoped_attributes = attributes.merge(self.class.scope_attributes)
389
861
 
390
862
  self.class.state_machines.initialize_states(self, {}, scoped_attributes)
391
863
  yield(*args) if block_given?
392
864
  end
393
865
  end
394
- end_eval
866
+ END_EVAL
395
867
  end
396
868
 
397
869
  # Uses around callbacks to run state events if using the :save hook
398
870
  def define_action_hook
399
871
  if action_hook == :save
400
- define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
872
+ define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
401
873
  def save(*, **)
402
874
  self.class.state_machine(#{name.inspect}).send(:around_save, self) { super }
403
875
  end
@@ -410,33 +882,42 @@ module StateMachines
410
882
  def changed_for_autosave?
411
883
  super || self.class.state_machines.any? {|name, machine| machine.action == :save && machine.read(self, :event)}
412
884
  end
413
- end_eval
885
+ END_EVAL
414
886
  else
415
887
  super
416
888
  end
417
889
  end
418
890
 
419
891
  # Runs state events around the machine's :save action
420
- def around_save(object)
421
- 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(&)
422
895
  end
423
896
 
424
897
  # Creates a scope for finding records *with* a particular state or
425
898
  # states for the attribute
426
- def create_with_scope(name)
427
- 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
428
908
  end
429
909
 
430
910
  # Creates a scope for finding records *without* a particular state or
431
911
  # states for the attribute
432
- def create_without_scope(name)
433
- create_scope(name, ->(values) { ["#{attribute_column} NOT IN (?)", values] })
434
- end
435
-
436
- # Generates the fully-qualifed column name for this machine's attribute
437
- def attribute_column
438
- connection = owner_class.connection
439
- "#{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
440
921
  end
441
922
 
442
923
  # Runs a new database transaction, rolling back any changes by raising
@@ -445,7 +926,7 @@ module StateMachines
445
926
  def transaction(object)
446
927
  result = nil
447
928
  object.class.transaction do
448
- raise ::ActiveRecord::Rollback unless result = yield
929
+ raise ::ActiveRecord::Rollback unless (result = yield)
449
930
  end
450
931
  result
451
932
  end
@@ -456,17 +937,12 @@ module StateMachines
456
937
 
457
938
  private
458
939
 
459
- # Defines a new scope with the given name
460
- def create_scope(name, scope)
461
- lambda { |model, values| model.where(scope.call(values)) }
462
- end
463
-
464
- # ActiveModel's use of method_missing / respond_to for attribute methods
465
- # breaks both ancestor lookups and defined?(super). Need to special-case
466
- # the existence of query attribute methods.
467
- def owner_class_ancestor_has_method?(scope, method)
468
- scope == :instance && method == "#{attribute}?" ? owner_class : super
469
- 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
470
946
  end
471
947
  register(ActiveRecord)
472
948
  end