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.
- checksums.yaml +4 -4
- data/README.md +173 -2
- data/lib/state_machines/integrations/active_record/locale.rb +10 -9
- data/lib/state_machines/integrations/active_record/type/integer.rb +122 -0
- data/lib/state_machines/integrations/active_record/version.rb +1 -1
- data/lib/state_machines/integrations/active_record.rb +514 -38
- metadata +36 -138
- data/test/files/en.yml +0 -5
- data/test/files/models/post.rb +0 -13
- data/test/integration_test.rb +0 -27
- data/test/machine_by_default_test.rb +0 -18
- data/test/machine_errors_test.rb +0 -21
- data/test/machine_multiple_test.rb +0 -19
- data/test/machine_nested_action_test.rb +0 -40
- data/test/machine_unmigrated_test.rb +0 -16
- data/test/machine_with_aliased_attribute_test.rb +0 -25
- data/test/machine_with_callbacks_test.rb +0 -174
- data/test/machine_with_column_state_attribute_test.rb +0 -46
- data/test/machine_with_complex_pluralization_scopes_test.rb +0 -18
- data/test/machine_with_conflicting_predicate_test.rb +0 -20
- data/test/machine_with_conflicting_state_name_test.rb +0 -31
- data/test/machine_with_custom_attribute_test.rb +0 -23
- data/test/machine_with_default_scope_test.rb +0 -20
- data/test/machine_with_different_column_default_test.rb +0 -29
- data/test/machine_with_different_integer_column_default_test.rb +0 -31
- data/test/machine_with_dirty_attribute_and_custom_attributes_during_loopback_test.rb +0 -26
- data/test/machine_with_dirty_attribute_and_state_events_test.rb +0 -22
- data/test/machine_with_dirty_attributes_and_custom_attribute_test.rb +0 -34
- data/test/machine_with_dirty_attributes_during_loopback_test.rb +0 -24
- data/test/machine_with_dirty_attributes_test.rb +0 -37
- data/test/machine_with_dynamic_initial_state_test.rb +0 -101
- data/test/machine_with_event_attributes_on_autosave_test.rb +0 -50
- data/test/machine_with_event_attributes_on_custom_action_test.rb +0 -43
- data/test/machine_with_event_attributes_on_save_bang_test.rb +0 -84
- data/test/machine_with_event_attributes_on_save_test.rb +0 -246
- data/test/machine_with_event_attributes_on_validation_test.rb +0 -145
- data/test/machine_with_events_test.rb +0 -15
- data/test/machine_with_failed_action_test.rb +0 -42
- data/test/machine_with_failed_after_callbacks_test.rb +0 -37
- data/test/machine_with_failed_before_callbacks_test.rb +0 -38
- data/test/machine_with_initialized_state_test.rb +0 -43
- data/test/machine_with_internationalization_test.rb +0 -182
- data/test/machine_with_loopback_test.rb +0 -24
- data/test/machine_with_non_column_state_attribute_defined_test.rb +0 -31
- data/test/machine_with_same_column_default_test.rb +0 -28
- data/test/machine_with_same_integer_column_default_test.rb +0 -32
- data/test/machine_with_scopes_and_joins_test.rb +0 -40
- data/test/machine_with_scopes_and_owner_subclass_test.rb +0 -29
- data/test/machine_with_scopes_test.rb +0 -72
- data/test/machine_with_state_driven_validations_test.rb +0 -32
- data/test/machine_with_states_test.rb +0 -15
- data/test/machine_with_static_initial_state_test.rb +0 -169
- data/test/machine_with_transactions_test.rb +0 -28
- data/test/machine_with_validations_and_custom_attribute_test.rb +0 -23
- data/test/machine_with_validations_test.rb +0 -49
- data/test/machine_without_database_test.rb +0 -22
- data/test/machine_without_transactions_test.rb +0 -28
- data/test/model_test.rb +0 -14
- 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
|
|
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
|
|
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 = {:
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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, <<-
|
|
856
|
+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
|
|
386
857
|
def initialize(attributes = nil, *)
|
|
387
858
|
super(attributes) do |*args|
|
|
388
|
-
|
|
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
|
-
|
|
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, <<-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
427
|
-
|
|
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(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|