statesman 3.5.0 → 7.4.0

Sign up to get free protection for your applications and to get access to all the features.
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]