state_machines-activerecord 0.103.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: 571fa09486dd051901ae113941b3ae04e9867da62da325c609f97f44f1272f66
4
- data.tar.gz: 22513e4df2238588d70dcff91f79fb00dfc18dac3a7b36a8a971765355939f8a
3
+ metadata.gz: f022e63c2bfd6af186733c95cbfc19cb7f25dfff128c791e5199b1a3f4e6d760
4
+ data.tar.gz: '094b3a2befdd974fa1828e9237f75f0a5a38319b7e38b56b8a3dda376a1e797d'
5
5
  SHA512:
6
- metadata.gz: a15cf9abb68277ce52ab35e139b15fa8ce6b28fb68424bcbcf840de0216267f7f58b62483c01b2cbd62df9694ba117dfee3b090e2b64692cfe9114396d64c0f2
7
- data.tar.gz: 97cb794b7e45222503e682641e80da05282f3593453979650431f35fa687831e9349dabe46ea7f2969dc2f95e9b3bc181cfc5860af17e3d57aefb72a8144a485
6
+ metadata.gz: cfb9f8bca11c37eeaff38ff0a237c2cc241986bba88ee4a8859199ea05ee7d06b4449da73574e0125f678de3082f3ba4e472f491f0efe9100fc2b585cac5e818
7
+ data.tar.gz: 8fb449a5ead40d4cf43e92208d307bb34f557e9aae068cda123c968ded28aecfa7a97c416426b04f99afd2550a50b54f852bc6e04c5ab6dc4fef62199d5e615d
data/README.md CHANGED
@@ -164,6 +164,62 @@ machine.state_machine_methods
164
164
  # => ["status_pending?", "status_processing?", "status_completed?", "status_cancelled?", ...]
165
165
  ```
166
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.
167
223
 
168
224
  ### Requirements for Enum Integration
169
225
 
@@ -9,52 +9,113 @@ module StateMachines
9
9
  # States without explicit integer values are mapped by their index position
10
10
  # in the states collection (0, 1, 2, …). States with an explicit integer
11
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).
12
18
  class Integer < ::ActiveRecord::Type::Value
13
- def initialize(states)
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)
14
32
  @states = states
33
+ @raw_type = raw_type || ::ActiveModel::Type::Integer.new
15
34
  super()
16
35
  end
17
36
 
18
- # integer from DB state name string
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
19
42
  def deserialize(value)
43
+ states = named_states
44
+ return @raw_type.deserialize(value) if passthrough?(states)
20
45
  return nil if value.nil?
46
+
21
47
  int_val = value.to_i
22
- state = named_states.detect { |s| state_integer(s) == int_val }
48
+ state = states.detect { |s| state_integer(s, states) == int_val }
23
49
  state ? state.name.to_s : value
24
50
  end
25
51
 
26
- # assignment (symbol / string / integer) state name string (in-memory)
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
27
58
  def cast(value)
59
+ states = named_states
60
+ return @raw_type.cast(value) if passthrough?(states)
28
61
  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)
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)
31
65
  state ? state.name.to_s : value.to_s
32
66
  end
33
67
 
34
- # state name string integer for DB write
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
35
72
  def serialize(value)
73
+ states = named_states
74
+ return @raw_type.serialize(value) if passthrough?(states)
36
75
  return nil if value.nil?
37
- state = named_states.detect { |s| s.name.to_s == value.to_s }
38
- state ? state_integer(state) : value
76
+
77
+ state = states.detect { |s| s.name.to_s == value.to_s }
78
+ state ? state_integer(state, states) : value
39
79
  end
40
80
 
81
+ # @return [Symbol] the ActiveModel type identifier
41
82
  def type
42
83
  :integer
43
84
  end
44
85
 
45
86
  private
46
87
 
47
- # All non-nil states in definition order not memoized because states are
88
+ # All non-nil states in definition order, not memoized because states are
48
89
  # added to the collection after the type is instantiated.
90
+ #
91
+ # @return [Array<StateMachines::State>]
49
92
  def named_states
50
93
  @states.reject { |s| s.name.nil? }
51
94
  end
52
95
 
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)
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)
58
119
  end
59
120
  end
60
121
  end
@@ -3,7 +3,7 @@
3
3
  module StateMachines
4
4
  module Integrations
5
5
  module ActiveRecord
6
- VERSION = '0.103.0'
6
+ VERSION = '0.200.0'
7
7
  end
8
8
  end
9
9
  end
@@ -316,6 +316,12 @@ module StateMachines
316
316
  # * (8) *after_transition*
317
317
  # * (-) end transaction (if enabled)
318
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.
319
325
  #
320
326
  # == Internationalization
321
327
  #
@@ -372,6 +378,7 @@ module StateMachines
372
378
 
373
379
  # The default options to use for state machines using this integration
374
380
  @defaults = { action: :save, use_transactions: true }
381
+ @auto_convert_integer_state_attributes = true
375
382
 
376
383
  # Machine-specific methods for enum integration
377
384
  module MachineMethods
@@ -382,7 +389,24 @@ module StateMachines
382
389
  def after_initialize
383
390
  super
384
391
  initialize_enum_integration
385
- register_integer_type if integer_column? && !enum_integrated?
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
386
410
  end
387
411
 
388
412
  # Check if enum integration should be enabled for this machine
@@ -418,9 +442,39 @@ module StateMachines
418
442
  # Generate methods after each state addition if enum integration is enabled
419
443
  generate_state_machine_methods if enum_integrated?
420
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
+
421
450
  result
422
451
  end
423
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
+
424
478
  # Check if this machine has enum integration enabled
425
479
  def enum_integrated?
426
480
  enum_integration && enum_integration[:enabled]
@@ -451,33 +505,132 @@ module StateMachines
451
505
  !!@integer_type_registered
452
506
  end
453
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
+
454
583
  # Machine internals (state matching, validations) call read() to get the
455
- # current state value and compare it against state.value. Only override
456
- # when states have explicit integer values (e.g. state :pending, value: 0).
457
- # In that case the custom type returns a state name string but machine
458
- # internals need the raw integer for value-based lookup.
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.
459
592
  #
460
- # For auto-indexed states (no explicit value), state.value is the string
461
- # name so super() returns the right thing already.
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
462
597
  def read(object, attr_sym, ivar = false)
463
598
  return super unless integer_type_registered? && attr_sym == :state
464
- return super unless states_with_explicit_integer_values?
599
+ return super unless mixed_integer_state_values?
465
600
 
466
601
  raw = object.read_attribute_before_type_cast(attribute.to_s)
467
602
  if raw.is_a?(::String) || raw.is_a?(::Symbol)
468
603
  matched = states.detect { |s| s.name && s.name.to_s == raw.to_s }
469
- return matched ? matched.value : raw
604
+ return matched.value if matched
470
605
  end
471
- raw
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
472
610
  end
473
611
 
474
612
  private
475
613
 
476
- # Returns true when at least one named state has an explicit integer value.
477
- # Used to decide whether read() needs to return raw integers for machine
478
- # internals rather than relying on the custom type's string representation.
479
- def states_with_explicit_integer_values?
480
- states.any? { |s| s.name && s.value.is_a?(::Integer) }
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
481
634
  end
482
635
 
483
636
  # Returns true when the state machine attribute is backed by an integer column
@@ -497,7 +650,14 @@ module StateMachines
497
650
  def register_integer_type
498
651
  @raw_integer_column_default = owner_class.column_defaults[attribute.to_s]
499
652
  @integer_type_registered = true
500
- owner_class.attribute(attribute.to_s, StateMachines::Type::Integer.new(states))
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))
501
661
  end
502
662
 
503
663
  # Detect existing enum methods for this attribute
@@ -650,6 +810,8 @@ module StateMachines
650
810
  include MachineMethods
651
811
 
652
812
  class << self
813
+ attr_accessor :auto_convert_integer_state_attributes
814
+
653
815
  # Classes that inherit from ActiveRecord::Base will automatically use
654
816
  # the ActiveRecord integration.
655
817
  def matching_ancestors
@@ -674,6 +836,22 @@ module StateMachines
674
836
  owner_class.column_defaults[attribute.to_s]
675
837
  end
676
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
+
677
855
  def define_state_initializer
678
856
  define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
679
857
  def initialize(attributes = nil, *)
@@ -759,12 +937,6 @@ module StateMachines
759
937
 
760
938
  private
761
939
 
762
- # Generates the results for the given scope based on one or more states to filter by
763
- def run_scope(scope, machine, klass, states)
764
- values = states.flatten.compact.map { |state| machine.states.fetch(state).value }
765
- scope.call(klass, values)
766
- end
767
-
768
940
  # ActiveModel's use of method_missing / respond_to for attribute methods
769
941
  # breaks both ancestor lookups and defined?(super). Need to special-case
770
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.103.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.102.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.102.0
40
+ version: 0.200.0
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: appraisal
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -142,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
142
  - !ruby/object:Gem::Version
143
143
  version: '0'
144
144
  requirements: []
145
- rubygems_version: 4.0.3
145
+ rubygems_version: 4.0.10
146
146
  specification_version: 4
147
147
  summary: State machines Active Record Integration
148
148
  test_files: []