statesman 3.5.0 → 7.4.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.
Files changed (56) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +49 -250
  3. data/.rubocop.yml +1 -1
  4. data/.rubocop_todo.yml +26 -6
  5. data/CHANGELOG.md +106 -0
  6. data/Gemfile +10 -4
  7. data/Guardfile +2 -0
  8. data/README.md +78 -48
  9. data/Rakefile +2 -4
  10. data/lib/generators/statesman/active_record_transition_generator.rb +2 -0
  11. data/lib/generators/statesman/generator_helpers.rb +2 -0
  12. data/lib/generators/statesman/migration_generator.rb +2 -0
  13. data/lib/statesman.rb +14 -4
  14. data/lib/statesman/adapters/active_record.rb +259 -37
  15. data/lib/statesman/adapters/active_record_queries.rb +100 -36
  16. data/lib/statesman/adapters/active_record_transition.rb +2 -0
  17. data/lib/statesman/adapters/memory.rb +2 -0
  18. data/lib/statesman/adapters/memory_transition.rb +2 -0
  19. data/lib/statesman/callback.rb +2 -0
  20. data/lib/statesman/config.rb +28 -0
  21. data/lib/statesman/exceptions.rb +34 -2
  22. data/lib/statesman/guard.rb +3 -4
  23. data/lib/statesman/machine.rb +29 -7
  24. data/lib/statesman/railtie.rb +2 -0
  25. data/lib/statesman/utils.rb +2 -0
  26. data/lib/statesman/version.rb +3 -1
  27. data/lib/tasks/statesman.rake +3 -1
  28. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_with_partial_index.rb +2 -0
  29. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions_without_partial_index.rb +2 -0
  30. data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +2 -0
  31. data/spec/generators/statesman/active_record_transition_generator_spec.rb +2 -0
  32. data/spec/generators/statesman/migration_generator_spec.rb +2 -0
  33. data/spec/spec_helper.rb +3 -30
  34. data/spec/statesman/adapters/active_record_queries_spec.rb +167 -91
  35. data/spec/statesman/adapters/active_record_spec.rb +15 -1
  36. data/spec/statesman/adapters/active_record_transition_spec.rb +2 -0
  37. data/spec/statesman/adapters/memory_spec.rb +2 -0
  38. data/spec/statesman/adapters/memory_transition_spec.rb +2 -0
  39. data/spec/statesman/adapters/shared_examples.rb +2 -0
  40. data/spec/statesman/callback_spec.rb +2 -0
  41. data/spec/statesman/config_spec.rb +2 -0
  42. data/spec/statesman/exceptions_spec.rb +88 -0
  43. data/spec/statesman/guard_spec.rb +2 -0
  44. data/spec/statesman/machine_spec.rb +79 -4
  45. data/spec/statesman/utils_spec.rb +2 -0
  46. data/spec/support/active_record.rb +9 -12
  47. data/spec/support/generators_shared_examples.rb +2 -0
  48. data/statesman.gemspec +19 -7
  49. metadata +40 -32
  50. data/lib/generators/statesman/mongoid_transition_generator.rb +0 -25
  51. data/lib/generators/statesman/templates/mongoid_transition_model.rb.erb +0 -14
  52. data/lib/statesman/adapters/mongoid.rb +0 -66
  53. data/lib/statesman/adapters/mongoid_transition.rb +0 -10
  54. data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -23
  55. data/spec/statesman/adapters/mongoid_spec.rb +0 -86
  56. data/spec/support/mongoid.rb +0 -28
@@ -1,51 +1,124 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  module Adapters
3
5
  module ActiveRecordQueries
6
+ def self.check_missing_methods!(base)
7
+ missing_methods = %i[transition_class initial_state].
8
+ reject { |m| base.respond_to?(m) }
9
+ return if missing_methods.none?
10
+
11
+ raise NotImplementedError,
12
+ "#{missing_methods.join(', ')} method(s) should be defined on " \
13
+ "the model. Alternatively, use the new form of `include " \
14
+ "Statesman::Adapters::ActiveRecordQueries[" \
15
+ "transition_class: MyTransition, " \
16
+ "initial_state: :some_state]`"
17
+ end
18
+
4
19
  def self.included(base)
5
- base.extend(ClassMethods)
20
+ check_missing_methods!(base)
21
+
22
+ base.include(
23
+ ClassMethods.new(
24
+ transition_class: base.transition_class,
25
+ initial_state: base.initial_state,
26
+ most_recent_transition_alias: base.try(:most_recent_transition_alias),
27
+ transition_name: base.try(:transition_name),
28
+ ),
29
+ )
6
30
  end
7
31
 
8
- module ClassMethods
9
- def in_state(*states)
10
- states = states.flatten.map(&:to_s)
32
+ def self.[](**args)
33
+ ClassMethods.new(**args)
34
+ end
11
35
 
12
- joins(most_recent_transition_join).
13
- where(states_where(most_recent_transition_alias, states), states)
36
+ class ClassMethods < Module
37
+ def initialize(**args)
38
+ @args = args
14
39
  end
15
40
 
16
- def not_in_state(*states)
17
- states = states.flatten.map(&:to_s)
41
+ def included(base)
42
+ ensure_inheritance(base)
18
43
 
19
- joins(most_recent_transition_join).
20
- where("NOT (#{states_where(most_recent_transition_alias, states)})",
21
- states)
22
- end
44
+ query_builder = QueryBuilder.new(base, **@args)
23
45
 
24
- def most_recent_transition_join
25
- "LEFT OUTER JOIN #{model_table} AS #{most_recent_transition_alias}
26
- ON #{table_name}.id =
27
- #{most_recent_transition_alias}.#{model_foreign_key}
28
- AND #{most_recent_transition_alias}.most_recent = #{db_true}"
46
+ base.define_singleton_method(:most_recent_transition_join) do
47
+ query_builder.most_recent_transition_join
48
+ end
49
+
50
+ define_in_state(base, query_builder)
51
+ define_not_in_state(base, query_builder)
29
52
  end
30
53
 
31
54
  private
32
55
 
33
- def transition_class
34
- raise NotImplementedError, "A transition_class method should be " \
35
- "defined on the model"
56
+ def ensure_inheritance(base)
57
+ klass = self
58
+ existing_inherited = base.method(:inherited)
59
+ base.define_singleton_method(:inherited) do |subclass|
60
+ existing_inherited.call(subclass)
61
+ subclass.send(:include, klass)
62
+ end
63
+ end
64
+
65
+ def define_in_state(base, query_builder)
66
+ base.define_singleton_method(:in_state) do |*states|
67
+ states = states.flatten
68
+
69
+ joins(most_recent_transition_join).
70
+ where(query_builder.states_where(states), states)
71
+ end
72
+ end
73
+
74
+ def define_not_in_state(base, query_builder)
75
+ base.define_singleton_method(:not_in_state) do |*states|
76
+ states = states.flatten
77
+
78
+ joins(most_recent_transition_join).
79
+ where("NOT (#{query_builder.states_where(states)})", states)
80
+ end
81
+ end
82
+ end
83
+
84
+ class QueryBuilder
85
+ def initialize(model, transition_class:, initial_state:,
86
+ most_recent_transition_alias: nil,
87
+ transition_name: nil)
88
+ @model = model
89
+ @transition_class = transition_class
90
+ @initial_state = initial_state
91
+ @most_recent_transition_alias = most_recent_transition_alias
92
+ @transition_name = transition_name
36
93
  end
37
94
 
38
- def initial_state
39
- raise NotImplementedError, "An initial_state method should be " \
40
- "defined on the model"
95
+ def states_where(states)
96
+ if initial_state.to_s.in?(states.map(&:to_s))
97
+ "#{most_recent_transition_alias}.to_state IN (?) OR " \
98
+ "#{most_recent_transition_alias}.to_state IS NULL"
99
+ else
100
+ "#{most_recent_transition_alias}.to_state IN (?) AND " \
101
+ "#{most_recent_transition_alias}.to_state IS NOT NULL"
102
+ end
103
+ end
104
+
105
+ def most_recent_transition_join
106
+ "LEFT OUTER JOIN #{model_table} AS #{most_recent_transition_alias} " \
107
+ "ON #{model.table_name}.id = " \
108
+ "#{most_recent_transition_alias}.#{model_foreign_key} " \
109
+ "AND #{most_recent_transition_alias}.most_recent = #{db_true}"
41
110
  end
42
111
 
112
+ private
113
+
114
+ attr_reader :model, :transition_class, :initial_state
115
+
43
116
  def transition_name
44
- transition_class.table_name.to_sym
117
+ @transition_name || transition_class.table_name.to_sym
45
118
  end
46
119
 
47
120
  def transition_reflection
48
- reflect_on_all_associations(:has_many).each do |value|
121
+ model.reflect_on_all_associations(:has_many).each do |value|
49
122
  return value if value.klass == transition_class
50
123
  end
51
124
 
@@ -62,18 +135,9 @@ module Statesman
62
135
  transition_reflection.table_name
63
136
  end
64
137
 
65
- def states_where(temporary_table_name, states)
66
- if initial_state.to_s.in?(states.map(&:to_s))
67
- "#{temporary_table_name}.to_state IN (?) OR " \
68
- "#{temporary_table_name}.to_state IS NULL"
69
- else
70
- "#{temporary_table_name}.to_state IN (?) AND " \
71
- "#{temporary_table_name}.to_state IS NOT NULL"
72
- end
73
- end
74
-
75
138
  def most_recent_transition_alias
76
- "most_recent_#{transition_name.to_s.singularize}"
139
+ @most_recent_transition_alias ||
140
+ "most_recent_#{transition_name.to_s.singularize}"
77
141
  end
78
142
 
79
143
  def db_true
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module Statesman
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module Statesman
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  module Adapters
3
5
  class MemoryTransition
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "exceptions"
2
4
 
3
5
  module Statesman
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
  require_relative "exceptions"
3
5
 
@@ -12,5 +14,31 @@ module Statesman
12
14
  def storage_adapter(adapter_class)
13
15
  @adapter_class = adapter_class
14
16
  end
17
+
18
+ def mysql_gaplock_protection?
19
+ return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
20
+
21
+ # If our adapter class suggests we're using mysql, enable gaplock protection by
22
+ # default.
23
+ enable_mysql_gaplock_protection if mysql_adapter?(adapter_class)
24
+ @mysql_gaplock_protection
25
+ end
26
+
27
+ def enable_mysql_gaplock_protection
28
+ @mysql_gaplock_protection = true
29
+ end
30
+
31
+ private
32
+
33
+ def mysql_adapter?(adapter_class)
34
+ adapter_name = adapter_name(adapter_class)
35
+ return false unless adapter_name
36
+
37
+ adapter_name.start_with?("mysql")
38
+ end
39
+
40
+ def adapter_name(adapter_class)
41
+ adapter_class.respond_to?(:adapter_name) && adapter_class&.adapter_name
42
+ end
15
43
  end
16
44
  end
@@ -1,12 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  class InvalidStateError < StandardError; end
3
5
  class InvalidTransitionError < StandardError; end
4
6
  class InvalidCallbackError < StandardError; end
5
- class GuardFailedError < StandardError; end
6
- class TransitionFailedError < StandardError; end
7
7
  class TransitionConflictError < StandardError; end
8
8
  class MissingTransitionAssociation < StandardError; end
9
9
 
10
+ class TransitionFailedError < StandardError
11
+ def initialize(from, to)
12
+ @from = from
13
+ @to = to
14
+ super(_message)
15
+ end
16
+
17
+ attr_reader :from, :to
18
+
19
+ private
20
+
21
+ def _message
22
+ "Cannot transition from '#{from}' to '#{to}'"
23
+ end
24
+ end
25
+
26
+ class GuardFailedError < StandardError
27
+ def initialize(from, to)
28
+ @from = from
29
+ @to = to
30
+ super(_message)
31
+ end
32
+
33
+ attr_reader :from, :to
34
+
35
+ private
36
+
37
+ def _message
38
+ "Guard on transition from: '#{from}' to '#{to}' returned false"
39
+ end
40
+ end
41
+
10
42
  class UnserializedMetadataError < StandardError
11
43
  def initialize(transition_class_name)
12
44
  super(_message(transition_class_name))
@@ -1,13 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "callback"
2
4
  require_relative "exceptions"
3
5
 
4
6
  module Statesman
5
7
  class Guard < Callback
6
8
  def call(*args)
7
- unless super(*args)
8
- raise GuardFailedError,
9
- "Guard on transition from: '#{from}' to '#{to}' returned false"
10
- end
9
+ raise GuardFailedError.new(from, to) unless super(*args)
11
10
  end
12
11
  end
13
12
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative "version"
2
4
  require_relative "exceptions"
3
5
  require_relative "guard"
@@ -46,10 +48,12 @@ module Statesman
46
48
 
47
49
  def callbacks
48
50
  @callbacks ||= {
49
- before: [],
50
- after: [],
51
+ before: [],
52
+ after: [],
53
+ after_transition_failure: [],
54
+ after_guard_failure: [],
51
55
  after_commit: [],
52
- guards: [],
56
+ guards: [],
53
57
  }
54
58
  end
55
59
 
@@ -83,6 +87,16 @@ module Statesman
83
87
  from: options[:from], to: options[:to], &block)
84
88
  end
85
89
 
90
+ def after_transition_failure(options = {}, &block)
91
+ add_callback(callback_type: :after_transition_failure, callback_class: Callback,
92
+ from: options[:from], to: options[:to], &block)
93
+ end
94
+
95
+ def after_guard_failure(options = {}, &block)
96
+ add_callback(callback_type: :after_guard_failure, callback_class: Callback,
97
+ from: options[:from], to: options[:to], &block)
98
+ end
99
+
86
100
  def validate_callback_condition(options = { from: nil, to: nil })
87
101
  from = to_s_or_nil(options[:from])
88
102
  to = array_to_s_or_nil(options[:to])
@@ -219,6 +233,17 @@ module Statesman
219
233
  @storage_adapter.create(initial_state, new_state, metadata)
220
234
 
221
235
  true
236
+ rescue TransitionFailedError => e
237
+ execute_on_failure(:after_transition_failure, initial_state, new_state, e)
238
+ raise
239
+ rescue GuardFailedError => e
240
+ execute_on_failure(:after_guard_failure, initial_state, new_state, e)
241
+ raise
242
+ end
243
+
244
+ def execute_on_failure(phase, initial_state, new_state, exception)
245
+ callbacks = callbacks_for(phase, from: initial_state, to: new_state)
246
+ callbacks.each { |cb| cb.call(@object, exception) }
222
247
  end
223
248
 
224
249
  def execute(phase, initial_state, new_state, transition)
@@ -265,10 +290,7 @@ module Statesman
265
290
  to = to_s_or_nil(options[:to])
266
291
 
267
292
  successors = self.class.successors[from] || []
268
- unless successors.include?(to)
269
- raise TransitionFailedError,
270
- "Cannot transition from '#{from}' to '#{to}'"
271
- end
293
+ raise TransitionFailedError.new(from, to) unless successors.include?(to)
272
294
 
273
295
  # Call all guards, they raise exceptions if they fail
274
296
  guards_for(from: from, to: to).each do |guard|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  class Railtie < ::Rails::Railtie
3
5
  railtie_name :statesman
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
4
  module Utils
3
5
  def self.rails_major_version
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statesman
2
- VERSION = "3.5.0".freeze
4
+ VERSION = "7.4.0"
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :statesman do
2
4
  desc "Set most_recent to false for old transitions and to true for the "\
3
5
  "latest one. Safe to re-run"
@@ -19,7 +21,7 @@ namespace :statesman do
19
21
  batch_size = 500
20
22
 
21
23
  parent_class.find_in_batches(batch_size: batch_size) do |models|
22
- ActiveRecord::Base.transaction do
24
+ ActiveRecord::Base.transaction(requires_new: true) do
23
25
  if Statesman::Adapters::ActiveRecord.database_supports_partial_indexes?
24
26
  # Set all transitions' most_recent to FALSE
25
27
  transition_class.where(parent_fk => models.map(&:id)).
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddConstraintsToMostRecentForBaconTransitions < ActiveRecord::Migration
2
4
  disable_ddl_transaction!
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddConstraintsToMostRecentForBaconTransitions < ActiveRecord::Migration
2
4
  disable_ddl_transaction!
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AddMostRecentToBaconTransitions < ActiveRecord::Migration
2
4
  def up
3
5
  add_column :bacon_transitions, :most_recent, :boolean, null: true
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "support/generators_shared_examples"
3
5
  require "generators/statesman/active_record_transition_generator"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "spec_helper"
2
4
  require "support/generators_shared_examples"
3
5
  require "generators/statesman/migration_generator"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "statesman"
2
4
  require "sqlite3"
3
5
  require "mysql2"
@@ -20,36 +22,7 @@ RSpec.configure do |config|
20
22
  config.order = "random"
21
23
 
22
24
  def connection_failure
23
- if defined?(Moped)
24
- Moped::Errors::ConnectionFailure
25
- else
26
- Mongo::Error::NoServerAvailable
27
- end
28
- end
29
-
30
- if config.exclusion_filter[:mongo]
31
- puts "Skipping Mongo tests"
32
- else
33
- require "mongoid"
34
-
35
- # Try a mongo connection at the start of the suite and raise if it fails
36
- begin
37
- Mongoid.configure do |mongo_config|
38
- if defined?(Moped)
39
- mongo_config.connect_to("statesman_test")
40
- mongo_config.sessions["default"]["options"]["max_retries"] = 2
41
- else
42
- mongo_config.connect_to("statesman_test", server_selection_timeout: 2)
43
- end
44
- end
45
- # Attempting a mongo operation will trigger 2 retries then throw an
46
- # exception if mongo is not running.
47
- Mongoid.purge!
48
- rescue connection_failure => error
49
- puts "The spec suite requires MongoDB to be installed and running locally"
50
- puts "Mongo dependent specs can be filtered with rspec --tag '~mongo'"
51
- raise(error)
52
- end
25
+ Moped::Errors::ConnectionFailure if defined?(Moped)
53
26
  end
54
27
 
55
28
  if config.exclusion_filter[:active_record]