state_machines-activerecord 0.100.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8da92c0d0872bf79ae81017dd5d16459e5222d10ee304390ddff3af14f1e1e05
4
- data.tar.gz: c7ebd9fbdff4ee5bd2b0cca81b5daad186bb92157eddcb912829685571aa84eb
3
+ metadata.gz: f022e63c2bfd6af186733c95cbfc19cb7f25dfff128c791e5199b1a3f4e6d760
4
+ data.tar.gz: '094b3a2befdd974fa1828e9237f75f0a5a38319b7e38b56b8a3dda376a1e797d'
5
5
  SHA512:
6
- metadata.gz: 66d176f9efbc9d44ac2d5a5786f078e593d3590a99d855804ff4775649b18abae64264781f4ddc36a60ac253366aea0a9c02d3d1712d680d78fe443fffd437e7
7
- data.tar.gz: f5e7a924679499d596439009b9bad2633af5389069959016595d31c44243f7551893cd2e53769e5e1b3a99b979f8d6ad1fa24d86caaa9134f0aa63528739b108
6
+ metadata.gz: cfb9f8bca11c37eeaff38ff0a237c2cc241986bba88ee4a8859199ea05ee7d06b4449da73574e0125f678de3082f3ba4e472f491f0efe9100fc2b585cac5e818
7
+ data.tar.gz: 8fb449a5ead40d4cf43e92208d307bb34f557e9aae068cda123c968ded28aecfa7a97c416426b04f99afd2550a50b54f852bc6e04c5ab6dc4fef62199d5e615d
data/README.md CHANGED
@@ -77,7 +77,164 @@ Vehicle.with_state(params[:state]) # Returns all vehicles if par
77
77
  Vehicle.where(color: 'red').with_state(nil) # Returns all red vehicles (chainable)
78
78
  ```
79
79
 
80
- ### State driven validations
80
+ ## Rails Enum Integration
81
+
82
+ When your ActiveRecord model uses Rails enums and defines a state machine on the same attribute, this gem automatically detects the conflict and provides seamless integration. This prevents method name collisions between Rails enum methods and state machine methods.
83
+
84
+ ### Auto-Detection and Conflict Resolution
85
+
86
+ ```ruby
87
+ class Order < ApplicationRecord
88
+ # Rails enum definition
89
+ enum :status, { pending: 0, processing: 1, completed: 2, cancelled: 3 }
90
+
91
+ # State machine on the same attribute
92
+ state_machine :status do
93
+ state :pending, :processing, :completed, :cancelled
94
+
95
+ event :process do
96
+ transition pending: :processing
97
+ end
98
+
99
+ event :complete do
100
+ transition processing: :completed
101
+ end
102
+
103
+ event :cancel do
104
+ transition [:pending, :processing] => :cancelled
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ When enum integration is detected, the gem automatically:
111
+ - Preserves original Rails enum methods (`pending?`, `processing?`, etc.)
112
+ - Generates prefixed state machine methods to avoid conflicts (`status_pending?`, `status_processing?`, etc.)
113
+ - Creates prefixed scope methods (`Order.status_pending`, `Order.status_processing`, etc.)
114
+
115
+ ### Available Methods
116
+
117
+ **Original Rails enum methods (preserved):**
118
+ ```ruby
119
+ order = Order.create(status: :pending)
120
+ order.pending? # => true (Rails enum method)
121
+ order.processing? # => false (Rails enum method)
122
+ order.processing! # Sets status to :processing (Rails enum method)
123
+
124
+ Order.pending # Rails enum scope
125
+ Order.processing # Rails enum scope
126
+ ```
127
+
128
+ **Generated state machine methods (prefixed):**
129
+ ```ruby
130
+ # Predicate methods
131
+ order.status_pending? # => true (state machine method)
132
+ order.status_processing? # => false (state machine method)
133
+ order.status_completed? # => false (state machine method)
134
+
135
+ # Bang methods (for conflict resolution only)
136
+ # These are placeholders and raise runtime errors
137
+ order.status_processing! # => raises RuntimeError
138
+
139
+ # Scope methods
140
+ Order.status_pending # State machine scope
141
+ Order.status_processing # State machine scope
142
+ Order.not_status_pending # Negative state machine scope
143
+ ```
144
+
145
+ ### Introspection API
146
+
147
+ The integration provides a comprehensive introspection API for advanced use cases:
148
+
149
+ ```ruby
150
+ machine = Order.state_machine(:status)
151
+
152
+ # Check if enum integration is enabled
153
+ machine.enum_integrated? # => true
154
+
155
+ # Get the Rails enum mapping
156
+ machine.enum_mapping # => {"pending"=>0, "processing"=>1, "completed"=>2, "cancelled"=>3}
157
+
158
+ # Get original Rails enum methods that were preserved
159
+ machine.original_enum_methods
160
+ # => ["pending?", "processing?", "completed?", "cancelled?", "pending!", "processing!", ...]
161
+
162
+ # Get state machine methods that were generated
163
+ machine.state_machine_methods
164
+ # => ["status_pending?", "status_processing?", "status_completed?", "status_cancelled?", ...]
165
+ ```
166
+
167
+ ## Integer-backed state attributes
168
+
169
+ Integer columns whose states don't declare explicit values are converted
170
+ transparently: application code reads state names while the database stores
171
+ integers (mapped by definition order).
172
+
173
+ ```ruby
174
+ class Order < ApplicationRecord
175
+ state_machine :status, initial: :pending do
176
+ state :pending
177
+ state :approved
178
+ end
179
+ end
180
+
181
+ order = Order.create!
182
+ order.status = :approved
183
+ order.status # => "approved"
184
+ # The database stores 1.
185
+ ```
186
+
187
+ Machines where every state declares an explicit integer value keep the classic
188
+ raw-integer behavior automatically: reads return the integer and `status_name`
189
+ returns the state name:
190
+
191
+ ```ruby
192
+ class LegacyOrder < ApplicationRecord
193
+ self.table_name = "orders"
194
+
195
+ state_machine :status, initial: :pending do
196
+ state :pending, value: 0
197
+ state :approved, value: 1
198
+
199
+ event :approve do
200
+ transition pending: :approved
201
+ end
202
+ end
203
+ end
204
+
205
+ order = LegacyOrder.create!
206
+ order.approve!
207
+ order.status # => 1
208
+ order.status_name # => :approved
209
+ # The database stores 1.
210
+ ```
211
+
212
+ Applications that want no type conversion at all can disable it during boot,
213
+ before defining affected state machines:
214
+
215
+ ```ruby
216
+ # config/initializers/state_machines.rb
217
+
218
+ StateMachines::Integrations::ActiveRecord.auto_convert_integer_state_attributes = false
219
+ ```
220
+
221
+ With this setting disabled, integer-backed state attributes are left entirely
222
+ to ActiveRecord's normal integer handling.
223
+
224
+ ### Requirements for Enum Integration
225
+
226
+ - The state machine attribute must match an existing Rails enum attribute
227
+ - Auto-detection is enabled by default when this condition is met
228
+
229
+ ### Configuration Options
230
+
231
+ The enum integration supports several configuration options:
232
+
233
+ - `prefix` (default: true) - Adds a prefix to generated methods to avoid conflicts
234
+ - `suffix` (default: false) - Alternative naming strategy using suffixes instead of prefixes
235
+ - `scopes` (default: true) - Controls whether state machine scopes are generated
236
+
237
+ ## State driven validations
81
238
 
82
239
  As mentioned in `StateMachines::Machine#state`, you can define behaviors,
83
240
  like validations, that only execute for certain states. One *important*
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module Type
5
+ # Custom ActiveRecord attribute type for state machine attributes backed by
6
+ # integer columns. Handles bidirectional conversion between state name strings
7
+ # (used internally by the state machine) and integer values (stored in the DB).
8
+ #
9
+ # States without explicit integer values are mapped by their index position
10
+ # in the states collection (0, 1, 2, …). States with an explicit integer
11
+ # value (e.g. state :pending, value: 2) use that value directly.
12
+ #
13
+ # When *every* named state has an explicit integer value, the column already
14
+ # stores the canonical state values and no name<->integer conversion is
15
+ # needed. In that case the type delegates to the column's original integer
16
+ # type, preserving the classic raw-integer behavior
17
+ # (e.g. record.status # => 1, record.status_name # => :approved).
18
+ class Integer < ::ActiveRecord::Type::Value
19
+ # The column's original attribute type, exposed so re-registration
20
+ # (e.g. for STI subclasses) can reuse it instead of wrapping this type.
21
+ #
22
+ # @return [ActiveModel::Type::Value]
23
+ attr_reader :raw_type
24
+
25
+ # @param states [StateMachines::StateCollection] live collection of the
26
+ # machine's states; held by reference because states are defined after
27
+ # the type is registered
28
+ # @param raw_type [ActiveModel::Type::Value, nil] the column's original
29
+ # attribute type, used verbatim in passthrough mode so adapter-specific
30
+ # integer behavior (limits, range checks) is preserved
31
+ def initialize(states, raw_type: nil)
32
+ @states = states
33
+ @raw_type = raw_type || ::ActiveModel::Type::Integer.new
34
+ super()
35
+ end
36
+
37
+ # Converts an integer from the database to a state name string.
38
+ #
39
+ # @param value [Integer, String, nil] raw database value
40
+ # @return [String, Integer, nil] state name, or the original type's value
41
+ # in passthrough mode, or the raw value when no state matches
42
+ def deserialize(value)
43
+ states = named_states
44
+ return @raw_type.deserialize(value) if passthrough?(states)
45
+ return nil if value.nil?
46
+
47
+ int_val = value.to_i
48
+ state = states.detect { |s| state_integer(s, states) == int_val }
49
+ state ? state.name.to_s : value
50
+ end
51
+
52
+ # Converts an assigned value (symbol, string, or integer) to the in-memory
53
+ # state name string.
54
+ #
55
+ # @param value [Symbol, String, Integer, nil] assigned value
56
+ # @return [String, Integer, nil] state name, or the original type's cast
57
+ # in passthrough mode
58
+ def cast(value)
59
+ states = named_states
60
+ return @raw_type.cast(value) if passthrough?(states)
61
+ return nil if value.nil?
62
+
63
+ state = states.detect { |s| s.name.to_s == value.to_s }
64
+ state ||= states.detect { |s| state_integer(s, states) == value.to_i } if value.respond_to?(:to_i)
65
+ state ? state.name.to_s : value.to_s
66
+ end
67
+
68
+ # Converts a state name string to its integer for the database write.
69
+ #
70
+ # @param value [String, Symbol, Integer, nil] in-memory value
71
+ # @return [Integer, nil] integer to store
72
+ def serialize(value)
73
+ states = named_states
74
+ return @raw_type.serialize(value) if passthrough?(states)
75
+ return nil if value.nil?
76
+
77
+ state = states.detect { |s| s.name.to_s == value.to_s }
78
+ state ? state_integer(state, states) : value
79
+ end
80
+
81
+ # @return [Symbol] the ActiveModel type identifier
82
+ def type
83
+ :integer
84
+ end
85
+
86
+ private
87
+
88
+ # All non-nil states in definition order, not memoized because states are
89
+ # added to the collection after the type is instantiated.
90
+ #
91
+ # @return [Array<StateMachines::State>]
92
+ def named_states
93
+ @states.reject { |s| s.name.nil? }
94
+ end
95
+
96
+ # Whether the machine was defined for raw integer storage, in which case
97
+ # conversion would change documented behavior. True when every named state
98
+ # declares an explicit integer value. Evaluated lazily on every call
99
+ # because states (and their values) are defined after the type is
100
+ # registered. Uses value(false) so dynamic (Proc) state values are never
101
+ # evaluated for this metadata decision.
102
+ #
103
+ # @param states [Array<StateMachines::State>] pre-computed named states
104
+ # @return [Boolean]
105
+ def passthrough?(states)
106
+ states.any? && states.all? { |s| s.value(false).is_a?(::Integer) }
107
+ end
108
+
109
+ # The integer to use for storage: the explicit state value if set
110
+ # (e.g. state :pending, value: 2), otherwise the index position among
111
+ # named states.
112
+ #
113
+ # @param state [StateMachines::State]
114
+ # @param states [Array<StateMachines::State>] pre-computed named states
115
+ # @return [Integer]
116
+ def state_integer(state, states)
117
+ value = state.value(false)
118
+ value.is_a?(::Integer) ? value : states.index(state)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -3,7 +3,7 @@
3
3
  module StateMachines
4
4
  module Integrations
5
5
  module ActiveRecord
6
- VERSION = '0.100.0'
6
+ VERSION = '0.200.0'
7
7
  end
8
8
  end
9
9
  end
@@ -1,6 +1,9 @@
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
9
  module Integrations # :nodoc:
@@ -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
  #
@@ -312,6 +316,12 @@ module StateMachines
312
316
  # * (8) *after_transition*
313
317
  # * (-) end transaction (if enabled)
314
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.
315
325
  #
316
326
  # == Internationalization
317
327
  #
@@ -368,7 +378,440 @@ module StateMachines
368
378
 
369
379
  # The default options to use for state machines using this integration
370
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
+
371
812
  class << self
813
+ attr_accessor :auto_convert_integer_state_attributes
814
+
372
815
  # Classes that inherit from ActiveRecord::Base will automatically use
373
816
  # the ActiveRecord integration.
374
817
  def matching_ancestors
@@ -383,13 +826,32 @@ module StateMachines
383
826
  action == :save
384
827
  end
385
828
 
386
- # 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.
387
832
  def owner_class_attribute_default
833
+ return @raw_integer_column_default if defined?(@raw_integer_column_default)
388
834
  return unless owner_class.connected? && owner_class.table_exists?
389
835
 
390
836
  owner_class.column_defaults[attribute.to_s]
391
837
  end
392
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))
853
+ end
854
+
393
855
  def define_state_initializer
394
856
  define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
395
857
  def initialize(attributes = nil, *)
@@ -434,7 +896,7 @@ module StateMachines
434
896
 
435
897
  # Creates a scope for finding records *with* a particular state or
436
898
  # states for the attribute
437
- def create_with_scope(name)
899
+ def create_with_scope(_name)
438
900
  attr_name = attribute
439
901
  lambda do |klass, values|
440
902
  if values.present?
@@ -447,7 +909,7 @@ module StateMachines
447
909
 
448
910
  # Creates a scope for finding records *without* a particular state or
449
911
  # states for the attribute
450
- def create_without_scope(name)
912
+ def create_without_scope(_name)
451
913
  attr_name = attribute
452
914
  lambda do |klass, values|
453
915
  if values.present?
@@ -458,14 +920,13 @@ module StateMachines
458
920
  end
459
921
  end
460
922
 
461
-
462
923
  # Runs a new database transaction, rolling back any changes by raising
463
924
  # an ActiveRecord::Rollback exception if the yielded block fails
464
925
  # (i.e. returns false).
465
926
  def transaction(object)
466
927
  result = nil
467
928
  object.class.transaction do
468
- raise ::ActiveRecord::Rollback unless result = yield
929
+ raise ::ActiveRecord::Rollback unless (result = yield)
469
930
  end
470
931
  result
471
932
  end
@@ -476,13 +937,6 @@ module StateMachines
476
937
 
477
938
  private
478
939
 
479
-
480
- # Generates the results for the given scope based on one or more states to filter by
481
- def run_scope(scope, machine, klass, states)
482
- values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
483
- scope.call(klass, values)
484
- end
485
-
486
940
  # ActiveModel's use of method_missing / respond_to for attribute methods
487
941
  # breaks both ancestor lookups and defined?(super). Need to special-case
488
942
  # the existence of query attribute methods.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: state_machines-activerecord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.100.0
4
+ version: 0.200.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: 0.100.0
33
+ version: 0.200.0
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: 0.100.0
40
+ version: 0.200.0
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: appraisal
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -56,16 +56,16 @@ dependencies:
56
56
  name: minitest
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - ">="
59
+ - - '='
60
60
  - !ruby/object:Gem::Version
61
- version: 5.4.0
61
+ version: 5.27.0
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - ">="
66
+ - - '='
67
67
  - !ruby/object:Gem::Version
68
- version: 5.4.0
68
+ version: 5.27.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: minitest-reporters
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +100,14 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '1.3'
103
+ version: '2.1'
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '1.3'
110
+ version: '2.1'
111
111
  description: Adds support for creating state machines for attributes on ActiveRecord
112
112
  email:
113
113
  - terminale@gmail.com
@@ -121,6 +121,7 @@ files:
121
121
  - lib/state_machines-activerecord.rb
122
122
  - lib/state_machines/integrations/active_record.rb
123
123
  - lib/state_machines/integrations/active_record/locale.rb
124
+ - lib/state_machines/integrations/active_record/type/integer.rb
124
125
  - lib/state_machines/integrations/active_record/version.rb
125
126
  homepage: https://github.com/state-machines/state_machines-activerecord/
126
127
  licenses:
@@ -141,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  - !ruby/object:Gem::Version
142
143
  version: '0'
143
144
  requirements: []
144
- rubygems_version: 3.6.9
145
+ rubygems_version: 4.0.10
145
146
  specification_version: 4
146
147
  summary: State machines Active Record Integration
147
148
  test_files: []