statesman 1.1.0 → 1.2.0

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