state_machines-activerecord 0.100.0 → 0.102.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: 710df8aa712f81e7a44c6646930c1c9f4507eb5423ca752d4359fe09f27ebb2e
4
+ data.tar.gz: c881a6d3b25aa45df35c0d62473f3b8a1dfed4cf7298d1657bebdba8fa086468
5
5
  SHA512:
6
- metadata.gz: 66d176f9efbc9d44ac2d5a5786f078e593d3590a99d855804ff4775649b18abae64264781f4ddc36a60ac253366aea0a9c02d3d1712d680d78fe443fffd437e7
7
- data.tar.gz: f5e7a924679499d596439009b9bad2633af5389069959016595d31c44243f7551893cd2e53769e5e1b3a99b979f8d6ad1fa24d86caaa9134f0aa63528739b108
6
+ metadata.gz: 866e2c089a9b52d214f37094021a7a1b0d10b775ce844c21eb42df7827c05fcd023b811a41cb1994215231806271825cf5b150b896de8d1d3756576525266c9e
7
+ data.tar.gz: 6aa52b30f96174a033db1a9f189fe233327baf8b195ded225baad3f687952d0d210fa16430e629b22f031e42359f769dab3d2aebb3addae8ddec39fdf98dc6bb
data/README.md CHANGED
@@ -77,7 +77,108 @@ 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
+
168
+ ### Requirements for Enum Integration
169
+
170
+ - The state machine attribute must match an existing Rails enum attribute
171
+ - Auto-detection is enabled by default when this condition is met
172
+
173
+ ### Configuration Options
174
+
175
+ The enum integration supports several configuration options:
176
+
177
+ - `prefix` (default: true) - Adds a prefix to generated methods to avoid conflicts
178
+ - `suffix` (default: false) - Alternative naming strategy using suffixes instead of prefixes
179
+ - `scopes` (default: true) - Controls whether state machine scopes are generated
180
+
181
+ ## State driven validations
81
182
 
82
183
  As mentioned in `StateMachines::Machine#state`, you can define behaviors,
83
184
  like validations, that only execute for certain states. One *important*
@@ -0,0 +1,61 @@
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
+ class Integer < ::ActiveRecord::Type::Value
13
+ def initialize(states)
14
+ @states = states
15
+ super()
16
+ end
17
+
18
+ # integer from DB → state name string
19
+ def deserialize(value)
20
+ return nil if value.nil?
21
+ int_val = value.to_i
22
+ state = named_states.detect { |s| state_integer(s) == int_val }
23
+ state ? state.name.to_s : value
24
+ end
25
+
26
+ # assignment (symbol / string / integer) → state name string (in-memory)
27
+ def cast(value)
28
+ return nil if value.nil?
29
+ state = named_states.detect { |s| s.name.to_s == value.to_s }
30
+ state ||= named_states.detect { |s| state_integer(s) == value.to_i } if value.respond_to?(:to_i)
31
+ state ? state.name.to_s : value.to_s
32
+ end
33
+
34
+ # state name string → integer for DB write
35
+ def serialize(value)
36
+ return nil if value.nil?
37
+ state = named_states.detect { |s| s.name.to_s == value.to_s }
38
+ state ? state_integer(state) : value
39
+ end
40
+
41
+ def type
42
+ :integer
43
+ end
44
+
45
+ private
46
+
47
+ # All non-nil states in definition order — not memoized because states are
48
+ # added to the collection after the type is instantiated.
49
+ def named_states
50
+ @states.reject { |s| s.name.nil? }
51
+ end
52
+
53
+ # Returns the integer to use for storage:
54
+ # - explicit integer value if set (e.g. state :pending, value: 2)
55
+ # - otherwise the index position among named states
56
+ def state_integer(state)
57
+ state.value.is_a?(::Integer) ? state.value : named_states.index(state)
58
+ end
59
+ end
60
+ end
61
+ 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.102.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
  #
@@ -368,6 +372,251 @@ module StateMachines
368
372
 
369
373
  # The default options to use for state machines using this integration
370
374
  @defaults = { action: :save, use_transactions: true }
375
+
376
+ # Machine-specific methods for enum integration
377
+ module MachineMethods
378
+ # Enum integration metadata storage
379
+ attr_accessor :enum_integration
380
+
381
+ # Hook called after machine initialization
382
+ def after_initialize
383
+ super
384
+ initialize_enum_integration
385
+ register_integer_type if integer_column? && !enum_integrated?
386
+ end
387
+
388
+ # Check if enum integration should be enabled for this machine
389
+ def detect_enum_integration
390
+ return nil unless owner_class.defined_enums.key?(attribute.to_s)
391
+
392
+ # For now, auto-detect enum and enable basic integration
393
+ # Later we can add explicit configuration options
394
+ {
395
+ enabled: true,
396
+ prefix: true,
397
+ suffix: false,
398
+ scopes: true,
399
+ enum_values: owner_class.defined_enums[attribute.to_s] || {},
400
+ original_enum_methods: detect_existing_enum_methods,
401
+ state_machine_methods: []
402
+ }
403
+ end
404
+
405
+ # Initialize enum integration if enum is detected
406
+ def initialize_enum_integration
407
+ detected_config = detect_enum_integration
408
+ return unless detected_config
409
+
410
+ # Store enum integration metadata
411
+ self.enum_integration = detected_config
412
+ end
413
+
414
+ # Override state method to trigger method generation after states are defined
415
+ def state(*, &)
416
+ result = super
417
+
418
+ # Generate methods after each state addition if enum integration is enabled
419
+ generate_state_machine_methods if enum_integrated?
420
+
421
+ result
422
+ end
423
+
424
+ # Check if this machine has enum integration enabled
425
+ def enum_integrated?
426
+ enum_integration && enum_integration[:enabled]
427
+ end
428
+
429
+ # Get the enum mapping for this attribute
430
+ def enum_mapping
431
+ return {} unless enum_integrated?
432
+
433
+ enum_integration[:enum_values] || {}
434
+ end
435
+
436
+ # Get list of original enum methods that were preserved
437
+ def original_enum_methods
438
+ return [] unless enum_integrated?
439
+
440
+ enum_integration[:original_enum_methods] || []
441
+ end
442
+
443
+ # Get list of state machine methods that were generated
444
+ def state_machine_methods
445
+ return [] unless enum_integrated?
446
+
447
+ enum_integration[:state_machine_methods] || []
448
+ end
449
+
450
+ private
451
+
452
+ # Returns true when the state machine attribute is backed by an integer column
453
+ def integer_column?
454
+ return false unless owner_class.respond_to?(:type_for_attribute)
455
+ return false unless owner_class.connected? && owner_class.table_exists?
456
+
457
+ owner_class.type_for_attribute(attribute.to_s).type == :integer
458
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::ConnectionNotEstablished
459
+ false
460
+ end
461
+
462
+ # Registers a custom AR attribute type so that integer columns transparently
463
+ # convert between state name strings and stored integers.
464
+ # Saves the raw column default first so the conflicting-default check
465
+ # (which fires later, during initial_state=) still compares raw integers.
466
+ def register_integer_type
467
+ @raw_integer_column_default = owner_class.column_defaults[attribute.to_s]
468
+ owner_class.attribute(attribute.to_s, StateMachines::Type::Integer.new(states))
469
+ end
470
+
471
+ # Detect existing enum methods for this attribute
472
+ def detect_existing_enum_methods
473
+ return [] unless owner_class.defined_enums.key?(attribute.to_s)
474
+
475
+ enum_values = owner_class.defined_enums[attribute.to_s]
476
+ methods = []
477
+
478
+ enum_values.each_key do |value|
479
+ # Predicate methods like 'active?'
480
+ predicate = "#{value}?"
481
+ methods << predicate if owner_class.method_defined?(predicate)
482
+
483
+ # Bang methods like 'active!'
484
+ bang_method = "#{value}!"
485
+ methods << bang_method if owner_class.method_defined?(bang_method)
486
+
487
+ # Scope methods (class-level)
488
+ methods << value.to_s if owner_class.respond_to?(value)
489
+ methods << "not_#{value}" if owner_class.respond_to?("not_#{value}")
490
+ end
491
+
492
+ methods
493
+ end
494
+
495
+ # Generate method name with prefix/suffix based on configuration
496
+ def generate_state_method_name(state_name, method_type)
497
+ return state_name unless enum_integrated?
498
+
499
+ config = enum_integration
500
+ base_name = case method_type
501
+ when :predicate
502
+ "#{state_name}?"
503
+ when :bang
504
+ "#{state_name}!"
505
+ else
506
+ state_name.to_s
507
+ end
508
+
509
+ # Apply prefix
510
+ if config[:prefix]
511
+ prefix = config[:prefix] == true ? "#{attribute}_" : "#{config[:prefix]}_"
512
+ base_name = "#{prefix}#{base_name}"
513
+ end
514
+
515
+ # Apply suffix
516
+ if config[:suffix]
517
+ suffix = config[:suffix] == true ? "_#{attribute}" : "_#{config[:suffix]}"
518
+ base_name = base_name.gsub(/(\?|!)$/, "#{suffix}\\1")
519
+ base_name = "#{base_name}#{suffix}" unless base_name.end_with?('?', '!')
520
+ end
521
+
522
+ base_name
523
+ end
524
+
525
+ # Generate state machine methods with conflict resolution
526
+ def generate_state_machine_methods
527
+ return unless enum_integrated?
528
+
529
+ # Initialize tracking if not already done
530
+ @processed_states ||= Set.new
531
+ enum_integration[:state_machine_methods] ||= []
532
+
533
+ # Get all states for this machine
534
+ states.each do |state|
535
+ state_name = state.name.to_s
536
+ next if state.nil? # Skip nil state
537
+ next if @processed_states.include?(state_name) # Skip already processed states
538
+
539
+ # Generate predicate method (e.g., status_pending?)
540
+ predicate_method = generate_state_method_name(state_name, :predicate)
541
+ if predicate_method != "#{state_name}?"
542
+ define_state_predicate_method(state_name, predicate_method)
543
+ track_generated_method(predicate_method)
544
+ end
545
+
546
+ # Generate bang method (e.g., status_pending!)
547
+ bang_method = generate_state_method_name(state_name, :bang)
548
+ if bang_method != "#{state_name}!"
549
+ define_state_bang_method(state_name, bang_method)
550
+ track_generated_method(bang_method)
551
+ end
552
+
553
+ # Generate scope methods (e.g., status_pending) if scopes are enabled
554
+ if enum_integration[:scopes]
555
+ scope_method = generate_state_method_name(state_name, :scope)
556
+ if scope_method != state_name
557
+ define_state_scope_method(state_name, scope_method)
558
+ track_generated_method(scope_method)
559
+ end
560
+ end
561
+
562
+ # Mark this state as processed
563
+ @processed_states.add(state_name)
564
+ end
565
+ end
566
+
567
+ # Define a prefixed predicate method for a state
568
+ def define_state_predicate_method(state_name, method_name)
569
+ machine_attribute = attribute
570
+ target_state_name = state_name.to_sym
571
+ owner_class.define_method(method_name) do
572
+ machine = self.class.state_machine(machine_attribute)
573
+ machine.states.matches?(self, target_state_name)
574
+ end
575
+ end
576
+
577
+ # Define a prefixed bang method for a state
578
+ def define_state_bang_method(state_name, method_name)
579
+ owner_class.define_method(method_name) do
580
+ # Raise an error with actionable guidance
581
+ raise "#{method_name} is a conflict-resolution placeholder. " \
582
+ "Use the original enum method '#{state_name}!' or state machine events instead."
583
+ end
584
+ end
585
+
586
+ # Define a prefixed scope method for a state
587
+ def define_state_scope_method(state_name, method_name)
588
+ machine_attribute = attribute
589
+ scope_lambda = lambda do |value = true|
590
+ machine = state_machine(machine_attribute)
591
+ state_value = machine.states[state_name.to_sym].value
592
+ if value
593
+ where(machine_attribute => state_value)
594
+ else
595
+ where.not(machine_attribute => state_value)
596
+ end
597
+ end
598
+
599
+ owner_class.define_singleton_method(method_name, &scope_lambda)
600
+ owner_class.define_singleton_method("not_#{method_name}") do
601
+ public_send(method_name, false)
602
+ end
603
+ end
604
+
605
+ # Track generated state machine methods for introspection
606
+ def track_generated_method(method_name)
607
+ return unless enum_integrated?
608
+
609
+ # Use a Set to ensure no duplicates
610
+ enum_integration[:state_machine_methods] ||= []
611
+ return if enum_integration[:state_machine_methods].include?(method_name)
612
+
613
+ enum_integration[:state_machine_methods] << method_name
614
+ end
615
+ end
616
+
617
+ # Include MachineMethods to make enum integration methods available on machine instances
618
+ include MachineMethods
619
+
371
620
  class << self
372
621
  # Classes that inherit from ActiveRecord::Base will automatically use
373
622
  # the ActiveRecord integration.
@@ -383,8 +632,11 @@ module StateMachines
383
632
  action == :save
384
633
  end
385
634
 
386
- # Gets the db default for the machine's attribute
635
+ # Gets the db default for the machine's attribute.
636
+ # For integer columns the raw pre-type-registration default is returned so
637
+ # that check_conflicting_attribute_default can compare integers to integers.
387
638
  def owner_class_attribute_default
639
+ return @raw_integer_column_default if defined?(@raw_integer_column_default)
388
640
  return unless owner_class.connected? && owner_class.table_exists?
389
641
 
390
642
  owner_class.column_defaults[attribute.to_s]
@@ -434,7 +686,7 @@ module StateMachines
434
686
 
435
687
  # Creates a scope for finding records *with* a particular state or
436
688
  # states for the attribute
437
- def create_with_scope(name)
689
+ def create_with_scope(_name)
438
690
  attr_name = attribute
439
691
  lambda do |klass, values|
440
692
  if values.present?
@@ -447,7 +699,7 @@ module StateMachines
447
699
 
448
700
  # Creates a scope for finding records *without* a particular state or
449
701
  # states for the attribute
450
- def create_without_scope(name)
702
+ def create_without_scope(_name)
451
703
  attr_name = attribute
452
704
  lambda do |klass, values|
453
705
  if values.present?
@@ -458,14 +710,13 @@ module StateMachines
458
710
  end
459
711
  end
460
712
 
461
-
462
713
  # Runs a new database transaction, rolling back any changes by raising
463
714
  # an ActiveRecord::Rollback exception if the yielded block fails
464
715
  # (i.e. returns false).
465
716
  def transaction(object)
466
717
  result = nil
467
718
  object.class.transaction do
468
- raise ::ActiveRecord::Rollback unless result = yield
719
+ raise ::ActiveRecord::Rollback unless (result = yield)
469
720
  end
470
721
  result
471
722
  end
@@ -476,7 +727,6 @@ module StateMachines
476
727
 
477
728
  private
478
729
 
479
-
480
730
  # Generates the results for the given scope based on one or more states to filter by
481
731
  def run_scope(scope, machine, klass, states)
482
732
  values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
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.102.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.102.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.102.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.3
145
146
  specification_version: 4
146
147
  summary: State machines Active Record Integration
147
148
  test_files: []