statesman 1.1.0 → 1.2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -1
  3. data/.travis.yml +19 -4
  4. data/CHANGELOG.md +30 -0
  5. data/README.md +11 -35
  6. data/lib/generators/statesman/add_constraints_to_most_recent_generator.rb +28 -0
  7. data/lib/generators/statesman/add_most_recent_generator.rb +25 -0
  8. data/lib/generators/statesman/generator_helpers.rb +6 -2
  9. data/lib/generators/statesman/templates/add_constraints_to_most_recent_migration.rb.erb +13 -0
  10. data/lib/generators/statesman/templates/add_most_recent_migration.rb.erb +9 -0
  11. data/lib/generators/statesman/templates/create_migration.rb.erb +4 -3
  12. data/lib/generators/statesman/templates/update_migration.rb.erb +5 -4
  13. data/lib/statesman.rb +1 -0
  14. data/lib/statesman/adapters/active_record.rb +23 -8
  15. data/lib/statesman/adapters/active_record_queries.rb +62 -15
  16. data/lib/statesman/railtie.rb +9 -0
  17. data/lib/statesman/version.rb +1 -1
  18. data/lib/tasks/statesman.rake +49 -0
  19. data/spec/fixtures/add_constraints_to_most_recent_for_bacon_transitions.rb +13 -0
  20. data/spec/fixtures/add_most_recent_to_bacon_transitions.rb +9 -0
  21. data/spec/generators/statesman/active_record_transition_generator_spec.rb +0 -2
  22. data/spec/generators/statesman/add_constraints_to_most_recent_generator_spec.rb +38 -0
  23. data/spec/generators/statesman/add_most_recent_generator_spec.rb +35 -0
  24. data/spec/generators/statesman/migration_generator_spec.rb +10 -1
  25. data/spec/generators/statesman/mongoid_transition_generator_spec.rb +0 -2
  26. data/spec/spec_helper.rb +22 -7
  27. data/spec/statesman/adapters/active_record_queries_spec.rb +110 -28
  28. data/spec/statesman/adapters/active_record_spec.rb +61 -31
  29. data/spec/statesman/adapters/mongoid_spec.rb +8 -17
  30. data/spec/statesman/adapters/shared_examples.rb +10 -17
  31. data/spec/statesman/callback_spec.rb +2 -6
  32. data/spec/statesman/config_spec.rb +2 -5
  33. data/spec/statesman/guard_spec.rb +3 -9
  34. data/spec/statesman/machine_spec.rb +91 -129
  35. data/spec/support/active_record.rb +35 -4
  36. data/spec/support/generators_shared_examples.rb +1 -4
  37. data/statesman.gemspec +5 -3
  38. metadata +52 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 854b8cc89e069fa3322067960b77dd3cf82364bc
4
- data.tar.gz: 8b9dd4292751c9323ebcf59b4ee9373ef9ed42eb
3
+ metadata.gz: b7adec92ffe517ecf4227b1190dcf1289abce8fe
4
+ data.tar.gz: 2f5876f6d294df4f2d52272c473d5c4521884d1d
5
5
  SHA512:
6
- metadata.gz: 6df28629b85027c1a3d3ae6e04e676d6037f500ad9448a5f50afaf2ad21d3d7159537acbeb33786f99293ced225ed487fcfa897068adff2a10447df31ad333bc
7
- data.tar.gz: 5300f0546e4929cb29b5ab4d86af48a1e9232a443e179c9cec180ee82109e481e34a317e43b3fe5f077bbd1320d3b027e8976bdcdf89dea36d7b8f11fc02d0fa
6
+ metadata.gz: 9e9be2c9b771cbd4f2d19b7de27854f047659c33deb6907af3b38bb8130feda521f9faf22ffe7560dc01fed63390ab569aae6b8fe3469e1d4159efba430ba428
7
+ data.tar.gz: e4e509269a3fd7adcfc3cfa15be27cef5d5e36e8e04fea8acf3c1fb6cef213ed3d91174c0efee5985eb691ec15ab59d105579769e651b4e1ca1d6546eb2cdc99
data/.rubocop.yml CHANGED
@@ -4,8 +4,11 @@ AllCops:
4
4
  Include:
5
5
  - Rakefile
6
6
  - statesman.gemfile
7
+ - lib/tasks/*.rake
7
8
  Exclude:
8
- - vendor/**
9
+ - vendor/**/*
10
+ - .*/**
11
+ - spec/fixtures/**/*
9
12
 
10
13
  StringLiterals:
11
14
  Enabled: false
@@ -36,3 +39,6 @@ GuardClause:
36
39
 
37
40
  SingleSpaceBeforeFirstArg:
38
41
  Enabled: false
42
+
43
+ Style/DotPosition:
44
+ EnforcedStyle: 'trailing'
data/.travis.yml CHANGED
@@ -1,11 +1,26 @@
1
+ language: ruby
2
+
1
3
  rvm:
2
4
  - 2.1
3
5
  - 2.0.0
4
6
  - 1.9.3
7
+
8
+ sudo: false
9
+
5
10
  services: mongodb
11
+
12
+ before_script:
13
+ - mysql -e 'CREATE DATABASE statesman_test;'
14
+ - psql -c 'CREATE DATABASE statesman_test;' -U postgres
15
+
6
16
  script:
7
- - bundle exec rubocop
8
- - bundle exec rake
17
+ - bundle exec rubocop
18
+ - bundle exec rake
19
+
9
20
  env:
10
- - "RAILS_VERSION=4.1.7"
11
- - "RAILS_VERSION=4.2.0.rc2"
21
+ - "RAILS_VERSION=3.2.21"
22
+ - "RAILS_VERSION=4.0.12"
23
+ - "RAILS_VERSION=4.1.8"
24
+ - "RAILS_VERSION=4.2.0"
25
+ - "RAILS_VERSION=4.2.0 DATABASE_URL=mysql2://root@localhost/statesman_test"
26
+ - "RAILS_VERSION=4.2.0 DATABASE_URL=postgres://postgres@localhost/statesman_test"
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## v1.2.0 18 March 2015
2
+
3
+ *Changes*
4
+
5
+ - Add a `most_recent` column to transition tables to greatly speed up queries (ActiveRecord adapter only).
6
+ - All queries are backwards-compatible, so everything still works without the new column.
7
+ - The upgrade path is:
8
+ - Generate and run a migration for adding the column, by running `rails generate statesman:add_most_recent <ParentModel> <TransitionModel>`.
9
+ - Backfill the `most_recent` column on old records by running `rake statesman:backfill_most_recent[ParentModel] `.
10
+ - Add constraints and indexes to the transition table that make use of the new field, by running `rails g statesman:add_constraints_to_most_recent <ParentModel> <TransitionModel>`.
11
+ - The upgrade path has been designed to be zero-downtime, even on large tables. As a result, please note that queries will only use the `most_recent` field after the constraints have been added.
12
+ - `ActiveRecordQueries.{not_,}in_state` now accepts an array of states.
13
+
14
+
15
+ ## v1.1.0 9 December 2014
16
+ *Fixes*
17
+
18
+ - Support for Rails 4.2.0.rc2:
19
+ - Remove use of serialized_attributes when using 4.2+. (patch by [@greysteil](https://github.com/greysteil))
20
+ - Use reflect_on_association rather than directly using the reflections hash. (patch by [@timrogers](https://github.com/timrogers))
21
+ - Fix `ActiveRecordQueries.in_state` when `Model.initial_state` is defined as a symbol. (patch by [@isaacseymour](https://github.com/isaacseymour))
22
+
23
+ *Changes*
24
+
25
+ - Transition metadata now defaults to `{}` rather than `nil`. (patch by [@greysteil](https://github.com/greysteil))
26
+
27
+ ## v1.0.0 21 November 2014
28
+
29
+ No changes from v1.0.0.beta2
30
+
1
31
  ## v1.0.0.beta2 10 October 2014
2
32
  *Breaking changes*
3
33
 
data/README.md CHANGED
@@ -60,7 +60,7 @@ class Order < ActiveRecord::Base
60
60
  has_many :order_transitions
61
61
 
62
62
  def state_machine
63
- OrderStateMachine.new(self, transition_class: OrderTransition)
63
+ @state_machine ||= OrderStateMachine.new(self, transition_class: OrderTransition)
64
64
  end
65
65
 
66
66
  private
@@ -222,6 +222,13 @@ It is also possible to use the PostgreSQL JSON column if you are using Rails 4.
222
222
  * Remove `include Statesman::Adapters::ActiveRecordTransition` statement from your
223
223
  transition model
224
224
 
225
+ #### Creating transitions without using `#transition_to` with ActiveRecord
226
+
227
+ By default, Statesman will include a `most_recent` column on the transitions
228
+ table, and update its value each time `#transition_to` is called. If you create
229
+ transitions manually (for example to backfill for a new state) you will need to
230
+ set the `most_recent` attribute manually.
231
+
225
232
 
226
233
  ## Configuration
227
234
 
@@ -289,6 +296,9 @@ model object and transition object are passed as arguments to the callback.
289
296
  This callback can have side-effects as it will only be run once immediately
290
297
  after the transition.
291
298
 
299
+ If you specify `after_commit: true`, the callback will be executed once the
300
+ transition has been committed to the database.
301
+
292
302
  #### `Machine.new`
293
303
  ```ruby
294
304
  my_machine = Machine.new(my_model, transition_class: MyTransitionModel)
@@ -423,40 +433,6 @@ describe "some callback" do
423
433
  end
424
434
  ```
425
435
 
426
- #### Creating models in certain states
427
-
428
- Sometimes you'll want to test a guard/transition from one state to another, where the state you want to go from is not the initial state of the model. In this instance you'll need to construct a model instance in the state required. However, if you have strict guards, this can be a pain. One way to get around this in tests is to directly create the transitions in the database, hence avoiding the guards.
429
-
430
- We use [FactoryGirl](https://github.com/thoughtbot/factory_girl) for creating our test objects. Given an `Order` model that is backed by Statesman, we can easily set it up to be in a particular state:
431
-
432
- ```ruby
433
- factory :order do
434
- property "value"
435
- ...
436
-
437
- trait :shipped do
438
- after(:create) do |order|
439
- FactoryGirl.create(:order_transition, :shipped, order: order)
440
- end
441
- end
442
- end
443
-
444
- factory :order_transition do
445
- order
446
- ...
447
-
448
- trait :shipped do
449
- to_state "shipped"
450
- end
451
- end
452
- ```
453
-
454
- This means you can easily create an `Order` in the `shipped` state:
455
-
456
- ```ruby
457
- let(:shipped_order) { FactoryGirl.create(:order, :shipped) }
458
- ```
459
-
460
436
  ---
461
437
 
462
438
  GoCardless ♥ open source. If you do too, come [join us](https://gocardless.com/jobs#software-engineer).
@@ -0,0 +1,28 @@
1
+ require "rails/generators"
2
+ require "generators/statesman/generator_helpers"
3
+
4
+ module Statesman
5
+ class AddConstraintsToMostRecentGenerator < Rails::Generators::Base
6
+ include Statesman::GeneratorHelpers
7
+
8
+ desc "Adds uniqueness and not-null constraints to the most recent column " \
9
+ "for a statesman transition"
10
+
11
+ argument :parent, type: :string, desc: "Your parent model name"
12
+ argument :klass, type: :string, desc: "Your transition model name"
13
+
14
+ source_root File.expand_path('../templates', __FILE__)
15
+
16
+ def create_model_file
17
+ template("add_constraints_to_most_recent_migration.rb.erb",
18
+ migration_file_name)
19
+ end
20
+
21
+ private
22
+
23
+ def migration_file_name
24
+ "db/migrate/#{next_migration_number}_"\
25
+ "add_constraints_to_most_recent_for_#{table_name}.rb"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ require "rails/generators"
2
+ require "generators/statesman/generator_helpers"
3
+
4
+ module Statesman
5
+ class AddMostRecentGenerator < Rails::Generators::Base
6
+ include Statesman::GeneratorHelpers
7
+
8
+ desc "Adds most_recent to a statesman transition model"
9
+
10
+ argument :parent, type: :string, desc: "Your parent model name"
11
+ argument :klass, type: :string, desc: "Your transition model name"
12
+
13
+ source_root File.expand_path('../templates', __FILE__)
14
+
15
+ def create_model_file
16
+ template("add_most_recent_migration.rb.erb", migration_file_name)
17
+ end
18
+
19
+ private
20
+
21
+ def migration_file_name
22
+ "db/migrate/#{next_migration_number}_add_most_recent_to_#{table_name}.rb"
23
+ end
24
+ end
25
+ end
@@ -28,9 +28,13 @@ module Statesman
28
28
  klass.demodulize.underscore.pluralize
29
29
  end
30
30
 
31
+ def index_name(index_id)
32
+ "index_#{table_name}_#{index_id}"
33
+ end
34
+
31
35
  def mysql?
32
- ActiveRecord::Base.configurations[Rails.env]
33
- .try(:[], "adapter").try(:match, /mysql/)
36
+ ActiveRecord::Base.configurations[Rails.env].
37
+ try(:[], "adapter").try(:match, /mysql/)
34
38
  end
35
39
  end
36
40
  end
@@ -0,0 +1,13 @@
1
+ class AddConstraintsToMostRecentFor<%= migration_class_name %> < ActiveRecord::Migration
2
+ disable_ddl_transaction!
3
+
4
+ def up
5
+ add_index :<%= table_name %>, [:<%= parent_id %>, :most_recent], unique: true, where: "most_recent", name: "index_<%= table_name %>_parent_most_recent", algorithm: :concurrently
6
+ change_column_null :<%= table_name %>, :most_recent, false
7
+ end
8
+
9
+ def down
10
+ remove_index :<%= table_name %>, name: "index_<%= table_name %>_parent_most_recent"
11
+ change_column_null :<%= table_name %>, :most_recent, true
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ class AddMostRecentTo<%= migration_class_name %> < ActiveRecord::Migration
2
+ def up
3
+ add_column :<%= table_name %>, :most_recent, :boolean, null: true
4
+ end
5
+
6
+ def down
7
+ remove_column :<%= table_name %>, :most_recent
8
+ end
9
+ end
@@ -5,10 +5,11 @@ class Create<%= migration_class_name %> < ActiveRecord::Migration
5
5
  t.text :metadata<%= ", default: \"{}\"" unless mysql? %>
6
6
  t.integer :sort_key, null: false
7
7
  t.integer :<%= parent_id %>, null: false
8
- t.timestamps
8
+ t.boolean :most_recent, null: false
9
+ t.timestamps null: false
9
10
  end
10
11
 
11
- add_index :<%= table_name %>, :<%= parent_id %>
12
- add_index :<%= table_name %>, [:sort_key, :<%= parent_id %>], unique: true
12
+ add_index :<%= table_name %>, [:<%= parent_id %>, :sort_key], unique: true, name: "<%= index_name :parent_sort %>"
13
+ add_index :<%= table_name %>, [:<%= parent_id %>, :most_recent], unique: true, where: "most_recent", name: "<%= index_name :parent_most_recent %>"
13
14
  end
14
15
  end
@@ -4,10 +4,11 @@ class AddStatesmanTo<%= migration_class_name %> < ActiveRecord::Migration
4
4
  add_column :<%= table_name %>, :metadata, :text<%= ", default: \"{}\"" unless mysql? %>
5
5
  add_column :<%= table_name %>, :sort_key, :integer, null: false
6
6
  add_column :<%= table_name %>, :<%= parent_id %>, :integer, null: false
7
- add_column :<%= table_name %>, :created_at, :datetime
8
- add_column :<%= table_name %>, :updated_at, :datetime
7
+ add_column :<%= table_name %>, :most_recent, null: false
8
+ add_column :<%= table_name %>, :created_at, :datetime, null: false
9
+ add_column :<%= table_name %>, :updated_at, :datetime, null: false
9
10
 
10
- add_index :<%= table_name %>, :<%= parent_id %>
11
- add_index :<%= table_name %>, [:sort_key, :<%= parent_id %>], unique: true
11
+ add_index :<%= table_name %>, [:<%= parent_id %>, :sort_key], unique: true, name: "<%= index_name :parent_sort %>"
12
+ add_index :<%= table_name %>, [:<%= parent_id %>, :most_recent], unique: true, where: "most_recent", name: "<%= index_name :parent_most_recent %>"
12
13
  end
13
14
  end
data/lib/statesman.rb CHANGED
@@ -15,6 +15,7 @@ module Statesman
15
15
  autoload :MongoidTransition,
16
16
  "statesman/adapters/mongoid_transition"
17
17
  end
18
+ require 'statesman/railtie' if defined?(::Rails::Railtie)
18
19
 
19
20
  # Example:
20
21
  # Statesman.configure do
@@ -27,8 +27,8 @@ module Statesman
27
27
  to = to.to_s
28
28
  create_transition(from, to, metadata)
29
29
  rescue ::ActiveRecord::RecordNotUnique => e
30
- if e.message.include?('sort_key') &&
31
- e.message.include?(@transition_class.table_name)
30
+ if e.message.include?(@transition_class.table_name) &&
31
+ e.message.include?('sort_key') || e.message.include?('most_recent')
32
32
  raise TransitionConflictError, e.message
33
33
  else raise
34
34
  end
@@ -53,11 +53,16 @@ module Statesman
53
53
  private
54
54
 
55
55
  def create_transition(from, to, metadata)
56
- transition = transitions_for_parent.build(to_state: to,
57
- sort_key: next_sort_key,
58
- metadata: metadata)
56
+ transition_attributes = { to_state: to,
57
+ sort_key: next_sort_key,
58
+ metadata: metadata }
59
+
60
+ transition_attributes.merge!(most_recent: true) if most_recent_column?
61
+
62
+ transition = transitions_for_parent.build(transition_attributes)
59
63
 
60
64
  ::ActiveRecord::Base.transaction do
65
+ unset_old_most_recent
61
66
  @observer.execute(:before, from, to, transition)
62
67
  transition.save!
63
68
  @last_transition = transition
@@ -72,14 +77,24 @@ module Statesman
72
77
  @parent_model.send(@transition_class.table_name)
73
78
  end
74
79
 
80
+ def unset_old_most_recent
81
+ return unless most_recent_column?
82
+ transitions_for_parent.update_all(most_recent: false)
83
+ end
84
+
85
+ def most_recent_column?
86
+ transition_class.columns_hash.include?("most_recent")
87
+ end
88
+
75
89
  def next_sort_key
76
90
  (last && last.sort_key + 10) || 0
77
91
  end
78
92
 
79
93
  def serialized?(transition_class)
80
- if ::ActiveRecord.gem_version >= Gem::Version.new('4.2.0.a')
81
- transition_class.columns_hash["metadata"]
82
- .cast_type.is_a?(::ActiveRecord::Type::Serialized)
94
+ if ::ActiveRecord.respond_to?(:gem_version) &&
95
+ ::ActiveRecord.gem_version >= Gem::Version.new('4.2.0.a')
96
+ transition_class.columns_hash["metadata"].
97
+ cast_type.is_a?(::ActiveRecord::Type::Serialized)
83
98
  else
84
99
  transition_class.serialized_attributes.include?("metadata")
85
100
  end
@@ -7,25 +7,51 @@ module Statesman
7
7
 
8
8
  module ClassMethods
9
9
  def in_state(*states)
10
- states = states.map(&:to_s)
10
+ states = states.flatten.map(&:to_s)
11
11
 
12
- joins(transition1_join)
13
- .joins(transition2_join)
14
- .where(state_inclusion_where(states), states)
15
- .where("transition2.id" => nil)
12
+ if use_most_recent_column?
13
+ in_state_with_most_recent(states)
14
+ else
15
+ in_state_without_most_recent(states)
16
+ end
16
17
  end
17
18
 
18
19
  def not_in_state(*states)
19
- states = states.map(&:to_s)
20
+ states = states.flatten.map(&:to_s)
20
21
 
21
- joins(transition1_join)
22
- .joins(transition2_join)
23
- .where("NOT (#{state_inclusion_where(states)})", states)
24
- .where("transition2.id" => nil)
22
+ if use_most_recent_column?
23
+ not_in_state_with_most_recent(states)
24
+ else
25
+ not_in_state_without_most_recent(states)
26
+ end
25
27
  end
26
28
 
27
29
  private
28
30
 
31
+ def in_state_with_most_recent(states)
32
+ joins(most_recent_transition_join).
33
+ where(states_where('last_transition', states), states)
34
+ end
35
+
36
+ def not_in_state_with_most_recent(states)
37
+ joins(most_recent_transition_join).
38
+ where("NOT (#{states_where('last_transition', states)})", states)
39
+ end
40
+
41
+ def in_state_without_most_recent(states)
42
+ joins(transition1_join).
43
+ joins(transition2_join).
44
+ where(states_where('transition1', states), states).
45
+ where("transition2.id" => nil)
46
+ end
47
+
48
+ def not_in_state_without_most_recent(states)
49
+ joins(transition1_join).
50
+ joins(transition2_join).
51
+ where("NOT (#{states_where('transition1', states)})", states).
52
+ where("transition2.id" => nil)
53
+ end
54
+
29
55
  def transition_class
30
56
  raise NotImplementedError, "A transition_class method should be " \
31
57
  "defined on the model"
@@ -55,15 +81,36 @@ module Statesman
55
81
  AND transition2.sort_key > transition1.sort_key"
56
82
  end
57
83
 
58
- def state_inclusion_where(states)
84
+ def most_recent_transition_join
85
+ "LEFT OUTER JOIN #{transition_name} AS last_transition
86
+ ON #{table_name}.id = last_transition.#{model_foreign_key}
87
+ AND last_transition.most_recent = #{db_true}"
88
+ end
89
+
90
+ def states_where(temporary_table_name, states)
59
91
  if initial_state.to_s.in?(states.map(&:to_s))
60
- 'transition1.to_state IN (?) OR ' \
61
- 'transition1.to_state IS NULL'
92
+ "#{temporary_table_name}.to_state IN (?) OR " \
93
+ "#{temporary_table_name}.to_state IS NULL"
62
94
  else
63
- 'transition1.to_state IN (?) AND ' \
64
- 'transition1.to_state IS NOT NULL'
95
+ "#{temporary_table_name}.to_state IN (?) AND " \
96
+ "#{temporary_table_name}.to_state IS NOT NULL"
65
97
  end
66
98
  end
99
+
100
+ def db_true
101
+ ::ActiveRecord::Base.connection.quote(true)
102
+ end
103
+
104
+ # Only use the most_recent column if it has a unique index guaranteeing
105
+ # it has good data
106
+ def use_most_recent_column?
107
+ ::ActiveRecord::Base.connection.index_exists?(
108
+ transition_name,
109
+ [model_foreign_key, :most_recent],
110
+ unique: true,
111
+ name: "index_#{transition_name}_parent_most_recent"
112
+ )
113
+ end
67
114
  end
68
115
  end
69
116
  end