Module: StateMachines::Integrations::ActiveRecord::MachineMethods

Included in:
StateMachines::Integrations::ActiveRecord
Defined in:
lib/state_machines/integrations/active_record.rb

Overview

Machine-specific methods for enum integration

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#enum_integrationObject

Enum integration metadata storage



386
387
388
# File 'lib/state_machines/integrations/active_record.rb', line 386

def enum_integration
  @enum_integration
end

Instance Method Details

#after_initializeObject

Hook called after machine initialization



389
390
391
392
393
394
# File 'lib/state_machines/integrations/active_record.rb', line 389

def after_initialize
  super
  initialize_enum_integration

  register_integer_type if register_integer_type?
end

#after_transition(*args, **options, &block) ⇒ Object

Creates a callback that will be invoked after a transition is performed, so long as the given requirements match the transition.

In addition to the configuration options supported by the core after_transition (see StateMachines::Machine#after_transition), the ActiveRecord integration supports:

  • :after_commit - Defer execution of the callback until the database transaction wrapping the transition has been committed. When no transaction is open at that point, the callback runs immediately. When the transaction (or an outer transaction wrapping it) is rolled back, the callback is discarded.

This is the safe place to enqueue background jobs that reference the record (e.g. via GlobalID), since a regular after_transition runs inside the transaction, before the record’s changes are visible to other connections:

class Vehicle < ApplicationRecord
  state_machine do
    after_transition on: :ignite, after_commit: true do |vehicle|
      EngineWarmupJob.perform_later(vehicle)
    end

    ...
  end
end

Note that a deferred callback cannot halt the callback chain or affect the result of the transition: by the time it runs, the transition has already been committed. For the same reason, an exception raised by a deferred callback is not propagated (doing so would revert the record’s in-memory state even though the database was already updated); it is reported to ActiveSupport.error_reporter (Rails.error) instead. Conditions (:if/:unless) and state requirements are evaluated when the transition is performed, not at commit time.

Like ActiveRecord’s own after_commit, a surrounding transaction(joinable: false) wrapper is transparent: the callback fires at the inner commit. This is what makes it fire under transactional test fixtures.



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/state_machines/integrations/active_record.rb', line 549

def after_transition(*args, **options, &block)
  # The flag may hide in a legacy trailing positional options hash
  positional_options = args.last.is_a?(Hash) ? args.pop.dup : {}
  options = positional_options.merge(options)

  # Only a boolean is the flag — a non-boolean value is the implicit
  # state-requirement form for a state named :after_commit
  flag = options[:after_commit]
  return super unless flag == true || flag == false

  options.delete(:after_commit)
  return super unless flag

  # Method handling goes to a real Callback (reusing core's binding,
  # arity and :do semantics); branch matching stays on the wrapper so
  # conditions are evaluated at transition time
  parsed = parse_callback_arguments(args, options)
  method_options = parsed.slice(:do, :bind_to_object)
  method_options[:terminator] = callback_terminator
  branch_options = parsed.except(:do, :bind_to_object, :terminator)

  deferred = Callback.new(:after, method_options, &block)

  super(**branch_options, bind_to_object: false) do |object, transition|
    object.class.current_transaction.after_commit do
      # The transition's catch(:halt) is gone at commit time
      catch(:halt) { deferred.call(object, {}, transition) }
    rescue StandardError => e
      # Raising would roll back in-memory state already committed; report instead
      ActiveSupport.error_reporter.report(e, handled: false, source: 'state_machines-activerecord')
    end
  end
end

#check_conflicting_attribute_defaultObject

Warns at most once when the column default conflicts with the machine’s initial state. The conflict is re-evaluated as states are defined, so remember when the warning has been issued.



456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/state_machines/integrations/active_record.rb', line 456

def check_conflicting_attribute_default
  return if @attribute_default_conflict_warned

  initial_state = states.detect(&:initial)
  conflict = !owner_class_attribute_default.nil? && (
    dynamic_initial_state? || !owner_class_attribute_default_matches?(initial_state)
  )
  return unless conflict

  @attribute_default_conflict_warned = true
  super
end

#detect_enum_integrationObject

Check if enum integration should be enabled for this machine



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/state_machines/integrations/active_record.rb', line 413

def detect_enum_integration
  return nil unless owner_class.defined_enums.key?(attribute.to_s)

  # For now, auto-detect enum and enable basic integration
  # Later we can add explicit configuration options
  {
    enabled: true,
    prefix: true,
    suffix: false,
    scopes: true,
    enum_values: owner_class.defined_enums[attribute.to_s] || {},
    original_enum_methods: detect_existing_enum_methods,
    state_machine_methods: []
  }
end

#enum_integrated?Boolean

Check if this machine has enum integration enabled

Returns:

  • (Boolean)


479
480
481
# File 'lib/state_machines/integrations/active_record.rb', line 479

def enum_integrated?
  enum_integration && enum_integration[:enabled]
end

#enum_mappingObject

Get the enum mapping for this attribute



484
485
486
487
488
# File 'lib/state_machines/integrations/active_record.rb', line 484

def enum_mapping
  return {} unless enum_integrated?

  enum_integration[:enum_values] || {}
end

#initialize_enum_integrationObject

Initialize enum integration if enum is detected



430
431
432
433
434
435
436
# File 'lib/state_machines/integrations/active_record.rb', line 430

def initialize_enum_integration
  detected_config = detect_enum_integration
  return unless detected_config

  # Store enum integration metadata
  self.enum_integration = detected_config
end

#integer_type_registered?Boolean

Returns:

  • (Boolean)


504
505
506
# File 'lib/state_machines/integrations/active_record.rb', line 504

def integer_type_registered?
  !!@integer_type_registered
end

#original_enum_methodsObject

Get list of original enum methods that were preserved



491
492
493
494
495
# File 'lib/state_machines/integrations/active_record.rb', line 491

def original_enum_methods
  return [] unless enum_integrated?

  enum_integration[:original_enum_methods] || []
end

#owner_class=(klass) ⇒ Class

Hook called when a machine is assigned to a class, including when an inherited machine is cloned for an STI subclass. The cloned machine carries the parent’s @integer_type_registered flag while the subclass still inherits the parent’s attribute type, which references the parent machine’s state collection. Re-register so the subclass type sees this machine’s (cloned) states, picking up subclass-added states as they are defined.

Parameters:

  • klass (Class)

    the new owner class

Returns:

  • (Class)

    the assigned owner class



406
407
408
409
410
# File 'lib/state_machines/integrations/active_record.rb', line 406

def owner_class=(klass)
  super.tap do
    register_integer_type if integer_type_registered?
  end
end

#read(object, attr_sym, ivar = false) ⇒ Object

Machine internals (state matching, validations) call read() to get the current state value and compare it against state.value. The custom integer type already returns the canonical value when state values are uniform: raw integers when every named state has an explicit integer value (passthrough), name strings when none do (state.value is the name). Only machines mixing explicit and auto-indexed values need an override, because the type returns name strings while the explicit states match on integers; map the stored value back to the matched state’s canonical state.value.

Parameters:

  • object (ActiveRecord::Base)

    record being read

  • attr_sym (Symbol)

    attribute kind (:state, :event, …)

  • ivar (Boolean) (defaults to: false)

    whether to read from an instance variable

Returns:

  • (Object)

    a value machine internals can match on state.value



597
598
599
600
601
602
603
604
605
606
607
608
609
610
# File 'lib/state_machines/integrations/active_record.rb', line 597

def read(object, attr_sym, ivar = false)
  return super unless integer_type_registered? && attr_sym == :state
  return super unless mixed_integer_state_values?

  raw = object.read_attribute_before_type_cast(attribute.to_s)
  if raw.is_a?(::String) || raw.is_a?(::Symbol)
    matched = states.detect { |s| s.name && s.name.to_s == raw.to_s }
    return matched.value if matched
  end

  name = owner_class.type_for_attribute(attribute.to_s).deserialize(raw)
  matched = states.detect { |s| s.name && s.name.to_s == name.to_s }
  matched ? matched.value : raw
end

#register_integer_type?Boolean

Returns true when this machine should use the custom integer attribute type to convert between Ruby state names and integer database values. This only applies to non-enum integer columns when automatic conversion is enabled.

Returns:

  • (Boolean)


472
473
474
475
476
# File 'lib/state_machines/integrations/active_record.rb', line 472

def register_integer_type?
  StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes &&
    integer_column? &&
    !enum_integrated?
end

#stateObject

Override state method to trigger method generation after states are defined



439
440
441
442
443
444
445
446
447
448
449
450
451
# File 'lib/state_machines/integrations/active_record.rb', line 439

def state(*, &)
  result = super

  # Generate methods after each state addition if enum integration is enabled
  generate_state_machine_methods if enum_integrated?

  # States defined after initialization (e.g. adding an explicit value
  # to the initial state) can change how the column default maps to
  # states, so re-evaluate the conflicting-default warning
  recheck_conflicting_attribute_default if integer_type_registered?

  result
end

#state_machine_methodsObject

Get list of state machine methods that were generated



498
499
500
501
502
# File 'lib/state_machines/integrations/active_record.rb', line 498

def state_machine_methods
  return [] unless enum_integrated?

  enum_integration[:state_machine_methods] || []
end