can_has_state 0.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 (40) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +238 -0
  3. data/Rakefile +38 -0
  4. data/lib/can_has_state/definition.rb +125 -0
  5. data/lib/can_has_state/machine.rb +104 -0
  6. data/lib/can_has_state/railtie.rb +11 -0
  7. data/lib/can_has_state/version.rb +3 -0
  8. data/lib/can_has_state.rb +7 -0
  9. data/lib/tasks/can_has_state_tasks.rake +4 -0
  10. data/test/can_has_state_test.rb +7 -0
  11. data/test/dummy/README.rdoc +261 -0
  12. data/test/dummy/Rakefile +7 -0
  13. data/test/dummy/app/assets/javascripts/application.js +15 -0
  14. data/test/dummy/app/assets/stylesheets/application.css +13 -0
  15. data/test/dummy/app/controllers/application_controller.rb +3 -0
  16. data/test/dummy/app/helpers/application_helper.rb +2 -0
  17. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  18. data/test/dummy/config/application.rb +59 -0
  19. data/test/dummy/config/boot.rb +10 -0
  20. data/test/dummy/config/database.yml +25 -0
  21. data/test/dummy/config/environment.rb +5 -0
  22. data/test/dummy/config/environments/development.rb +37 -0
  23. data/test/dummy/config/environments/production.rb +67 -0
  24. data/test/dummy/config/environments/test.rb +37 -0
  25. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  26. data/test/dummy/config/initializers/inflections.rb +15 -0
  27. data/test/dummy/config/initializers/mime_types.rb +5 -0
  28. data/test/dummy/config/initializers/secret_token.rb +7 -0
  29. data/test/dummy/config/initializers/session_store.rb +8 -0
  30. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  31. data/test/dummy/config/locales/en.yml +5 -0
  32. data/test/dummy/config/routes.rb +58 -0
  33. data/test/dummy/config.ru +4 -0
  34. data/test/dummy/public/404.html +26 -0
  35. data/test/dummy/public/422.html +26 -0
  36. data/test/dummy/public/500.html +25 -0
  37. data/test/dummy/public/favicon.ico +0 -0
  38. data/test/dummy/script/rails +6 -0
  39. data/test/test_helper.rb +15 -0
  40. metadata +169 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2012 thomas morgan
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # CanHasState #
2
+
3
+
4
+ `can_has_state` is a simplified state machine gem. It relies on ActiveModel and
5
+ should be compatible with any ActiveModel-compatible persistence layer.
6
+
7
+ Key features:
8
+
9
+ * Support for multiple state machines
10
+ * Simplified DSL syntax
11
+ * Few added methods avoids clobbering up model's namespace
12
+ * Compatible with ActionPack-style attribute value changes via
13
+ `:state_column => 'new_state'`
14
+ * Use any ActiveModel-compatible persistence layer (including your own)
15
+
16
+
17
+
18
+ ## DSL ##
19
+
20
+ ### ActiveRecord ###
21
+
22
+ class Account < ActiveRecord::Base
23
+
24
+ # Choose your state column name. In this case, it's :state.
25
+ # It's super easy to have multiple state machines.
26
+ #
27
+ state_machine :state do
28
+
29
+ # Define each possible state. Add :initial to indicate which state
30
+ # will be selected first, if the state hasn't been already set. If
31
+ # not provided, will set :initial to the first defined state.
32
+ #
33
+ # :on_enter and :on_exit trigger when this state is entered / exited.
34
+ # Symbols are assumed to be instance method names. Inline blocks may
35
+ # also be specified. The _deferred variations are discussed below
36
+ # under triggers.
37
+ #
38
+ state :active, :initial,
39
+ :from => :inactive,
40
+ :on_enter => :update_plan_details,
41
+ :on_enter_deferred => :start_billing,
42
+ :on_exit_deferred => lambda { |r| r.stop_billing }
43
+
44
+ # :from restricts which states can switch to this one. Multiple "from"
45
+ # states are allowed, as shown under state :deleted.
46
+ #
47
+ # If :from is not present, this state may be entered from any other
48
+ # state. To prevent ever moving to a given state (only useful if that
49
+ # state is also the initial state), use :from => [] .
50
+ #
51
+ state :inactive,
52
+ :from => [:active]
53
+
54
+ # :timestamp automatically sets the current date/time when this state
55
+ # is entered. Both *_at and *_on (datetime and date) columns are
56
+ # supported.
57
+ #
58
+ # :require adds additional restrictions before this state can be
59
+ # entered. Like :on_enter/:on_exit, it can be a method name or a
60
+ # lambda/Proc. Multiple methods may be provided. Each method must
61
+ # return a ruby true value (anything except nil or false) for the
62
+ # condition to be satisfied. If any :require is not true, then the
63
+ # state transition is blocked.
64
+ #
65
+ # :message allows the validation error message to be customized. It is
66
+ # used when conditions for either :from or :require fail. The default
67
+ # message is used in the example below. %{from} and %{to} parameters
68
+ # are optional, and will be the from and to states, respectively.
69
+ #
70
+ state :deleted,
71
+ :from => [:active, :inactive],
72
+ :timestamp => :deleted_at,
73
+ :on_enter_deferred => [:delete_record, :delete_payment_info],
74
+ :require => lambda {|r| !r.active_services? },
75
+ :message => "has invalid transition from %{from} to %{to}"
76
+
77
+ # Custom triggers are called for certain "from" => "to" state
78
+ # combinations. They are especially useful for DRYing up triggers
79
+ # that apply in multiple situations. They can also be used to apply
80
+ # triggers only in narrower situations than :on_enter/:on_exit above.
81
+
82
+ # This triggers *only* on :inactive => :active. It will not trigger on
83
+ # nil => :active (setting initial state) [see notes on triggers and
84
+ # initial state below].
85
+ #
86
+ on :inactive => :active, :trigger => :send_welcome_back_message
87
+
88
+ # Multiple triggers can be specified on either side of the transition
89
+ # or for the trigger actions:
90
+ #
91
+ on [:active, :inactive] => :deleted, :trigger => [:do_one, :do_two]
92
+ on :active => [:inactive, :deleted], :trigger => lambda {|r| r.act }
93
+
94
+ # If :deferred is included, and it's true, then this trigger will
95
+ # happen post-save, instead of pre-validation. Default pre-validation
96
+ # triggers are recommended for changing other attributes. Post-save
97
+ # triggers are useful for logging or cascading changes to association
98
+ # models. Deferred trigger actions are run within the same database
99
+ # transaction (for ActiveRecord and other ActiveModel children that
100
+ # implement this).
101
+ #
102
+ # Last, wildcards are supported. Note that the ruby parser requires a
103
+ # space after the asterisk for wildcards on the left side:
104
+ # works: :* =>:whatever
105
+ # doesn't: :*=>:whatever
106
+ #
107
+ # Utilize ActiveModel::Dirty's change history support to know what has
108
+ # changed:
109
+ # from = state_was # (specifically, <state_column>_was)
110
+ # to = state
111
+ #
112
+ on :* => :*, :trigger => :log_state_change, :deferred=>true
113
+ on :* => :deleted, :trigger => :same_as_on_enter
114
+
115
+ end
116
+
117
+ # If you want to set states via ActionController/ActionView, you'll
118
+ # probably want something like this.
119
+ attr_accessible :state, :as=>:admin
120
+
121
+ end
122
+
123
+
124
+ ### Just ActiveModel ###
125
+
126
+ class Account
127
+ include ActiveModel::Dirty
128
+ include ActiveModel::Validations
129
+ include ActiveModel::Validations::Callbacks
130
+ include CanHasState::Machine
131
+
132
+ state_machine :account_state do
133
+ state :active, :initial,
134
+ :from => :inactive
135
+ state :inactive,
136
+ :from => :active
137
+ state :deleted,
138
+ :from => [:active, :inactive],
139
+ :timestamp => :deleted_at
140
+ end
141
+
142
+ end
143
+
144
+
145
+
146
+ ## Managing states ##
147
+
148
+ States are set directly via the relevant state column--no added methods.
149
+
150
+ @account = Account.new
151
+ @account.save!
152
+ @account.state
153
+ # => 'active'
154
+
155
+ @account.state = 'deleted'
156
+ @account.valid?
157
+ # => true
158
+ @account.save!
159
+
160
+ @account.state = 'active'
161
+ @account.valid?
162
+ # => false
163
+
164
+
165
+ With multiple state machines on a single model, this also eliminates any name
166
+ collisions.
167
+
168
+ class Account < ActiveRecord::Base
169
+
170
+ # column is :state
171
+ state_machine :state do
172
+ state :active,
173
+ :from => :inactive,
174
+ :require => lambda{|r| r.payment_status=='current'}
175
+ state :inactive,
176
+ :from => :active
177
+ state :deleted,
178
+ :from => [:active, :inactive]
179
+ end
180
+
181
+ # column is :payment_status
182
+ state_machine :payment_status do
183
+ state :pending, :initial
184
+ state :current
185
+ state :overdue
186
+ end
187
+ end
188
+
189
+ @account = Account.new
190
+ @account.save!
191
+ @account.state
192
+ # => 'active'
193
+ @account.payment_status
194
+ # => 'pending'
195
+
196
+ @account.state = 'inactive'
197
+ @account.payment_status = 'overdue'
198
+ @account.save!
199
+
200
+
201
+ You can also check a potential state change using the method
202
+ `"allow_#{state_machine_column_name}?(potential_state)"`.
203
+
204
+ @account.allow_state? :active
205
+ # => false
206
+ @account.allow_payment_status? :pending
207
+ # => true
208
+
209
+
210
+ If you really want event-changing methods, it's just as straight-forward to
211
+ write them as normal methods instead of attempting to cram them into a DSL.
212
+
213
+ def delete!
214
+ self.state = 'deleted'
215
+ save!
216
+ end
217
+
218
+ When `save!` is called, the state changes will be validated and all triggers
219
+ will be called.
220
+
221
+
222
+
223
+ ## Notes on triggers and initial state ##
224
+
225
+ `can_has_state` relies on the `ActiveModel::Dirty` module to detect when a state
226
+ attribute has changed. In general, this shouldn't matter much to you.
227
+
228
+ However, triggers involving initial values can be tricky. If your database
229
+ schema sets the default value to the initial value, `:on_enter` and custom
230
+ triggers will *not* be called because nothing has changed. On the other hand,
231
+ if the state column defaults to a null value, then the triggers will be called
232
+ because the initial state value changed from nil to the initial state.
233
+
234
+
235
+
236
+ ## Compatibility ##
237
+
238
+ Tested with Ruby 1.9 and ActiveSupport and ActiveModel 3.2.8.
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'CanHasState'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+
24
+
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
28
+ require 'rake/testtask'
29
+
30
+ Rake::TestTask.new(:test) do |t|
31
+ t.libs << 'lib'
32
+ t.libs << 'test'
33
+ t.pattern = 'test/**/*_test.rb'
34
+ t.verbose = false
35
+ end
36
+
37
+
38
+ task :default => :test
@@ -0,0 +1,125 @@
1
+ module CanHasState
2
+ class Definition
3
+
4
+ attr_reader :column, :states, :initial_state, :triggers
5
+
6
+ def initialize(column_name, &block)
7
+ @column = column_name.to_sym
8
+ @states = {}
9
+ @triggers = []
10
+ instance_eval &block
11
+ @initial_state ||= @states.keys.first
12
+ end
13
+
14
+
15
+ def extend_machine(&block)
16
+ instance_eval &block
17
+ end
18
+
19
+
20
+ def state(state_name, *args)
21
+ options = args.extract_options!
22
+ state_name = state_name.to_s
23
+
24
+ if args.include? :initial
25
+ @initial_state = state_name
26
+ end
27
+
28
+ # TODO: turn even guards into types of triggers ... then support :guard as a trigger param
29
+ guards = []
30
+ message = "has invalid transition from %{from} to %{to}"
31
+ # TODO: differentiate messages for :from errors vs. :guard errors
32
+
33
+ options.each do |key, val|
34
+ case key
35
+ when :from
36
+ from_vals = Array(val).map(&:to_s)
37
+ from_vals << nil # for new records
38
+ guards << Proc.new{|r| from_vals.include? r.send("#{column}_was").try(:to_s)}
39
+ when :guard, :require
40
+ guards += Array(val)
41
+ when :message
42
+ message = val
43
+ when :timestamp
44
+ @triggers << {:from=>["*"], :to=>[state_name], :trigger=>[Proc.new{|r| r.send("#{val}=", Time.current)}]}
45
+ when :on_enter
46
+ @triggers << {:from=>["*"], :to=>[state_name], :trigger=>Array(val), :type=>:on_enter}
47
+ when :on_enter_deferred
48
+ @triggers << {:from=>["*"], :to=>[state_name], :trigger=>Array(val), :type=>:on_enter, :deferred=>true}
49
+ when :on_exit
50
+ @triggers << {:from=>[state_name], :to=>["*"], :trigger=>Array(val), :type=>:on_exit}
51
+ when :on_exit_deferred
52
+ @triggers << {:from=>[state_name], :to=>["*"], :trigger=>Array(val), :type=>:on_exit, :deferred=>true}
53
+ end
54
+ end
55
+
56
+ @states[state_name] = {:guards=>guards, :message=>message}
57
+ end
58
+
59
+
60
+ def on(pairs)
61
+ trigger = pairs.delete :trigger
62
+ deferred = pairs.delete :deferred
63
+ pairs.each do |from, to|
64
+ @triggers << {:from=>Array(from).map(&:to_s), :to=>Array(to).map(&:to_s),
65
+ :trigger=>Array(trigger), :type=>:trigger, :deferred=>!!deferred}
66
+ end
67
+ end
68
+
69
+
70
+
71
+ def known?(to)
72
+ @states.keys.include? to
73
+ end
74
+
75
+ def allow?(record, to)
76
+ return false unless known?(to)
77
+ states[to][:guards].all? do |g|
78
+ case g
79
+ when Proc
80
+ g.call record
81
+ when Symbol, String
82
+ record.send g
83
+ else
84
+ raise ArgumentError, "Expecing Symbol or Proc for :guard, got #{g.class} : #{g}"
85
+ end
86
+ end
87
+ end
88
+
89
+ def message(to)
90
+ states[to][:message]
91
+ end
92
+
93
+
94
+ def trigger(record, from, to, deferred=false)
95
+ # Rails.logger.debug "Checking triggers for transition #{from} to #{to} (deferred:#{deferred.inspect})"
96
+ @triggers.select do |trigger|
97
+ deferred ? trigger[:deferred] : !trigger[:deferred]
98
+ end.select do |trigger|
99
+ (trigger[:from].include?("*") || trigger[:from].include?(from)) &&
100
+ (trigger[:to].include?("*") || trigger[:to].include?(to))
101
+ # end.each do |trigger|
102
+ # Rails.logger.debug " Matched trigger: #{trigger[:from].inspect} -- #{trigger[:to].inspect}"
103
+ end.each do |trigger|
104
+ call_triggers record, trigger
105
+ end
106
+ end
107
+
108
+
109
+ private
110
+
111
+ def call_triggers(record, trigger)
112
+ trigger[:trigger].each do |m|
113
+ case m
114
+ when Proc
115
+ m.call record
116
+ when Symbol, String
117
+ record.send m
118
+ else
119
+ raise ArgumentError, "Expecing Symbol or Proc for #{trigger[:type].inspect}, got #{m.class} : #{m}"
120
+ end
121
+ end
122
+ end
123
+
124
+ end
125
+ end
@@ -0,0 +1,104 @@
1
+ module CanHasState
2
+ module Machine
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+
7
+ def state_machine(column, &block)
8
+ d = Definition.new(column, &block)
9
+
10
+ define_method "allow_#{column}?" do |to|
11
+ state_machine_allow?(column.to_sym, to.to_s)
12
+ end
13
+
14
+ self.state_machines += [[column.to_sym, d]]
15
+ end
16
+
17
+ def extend_state_machine(column, &block)
18
+ sm = state_machines.detect{|(col, stm)| col == column}
19
+ raise("Unknown state machine #{column}") unless sm
20
+ sm[1].extend_machine &block
21
+ end
22
+
23
+ end
24
+
25
+ included do
26
+ unless method_defined? :state_machines
27
+ class_attribute :state_machines, :instance_writer=>false
28
+ self.state_machines = []
29
+ end
30
+ before_validation :can_has_initial_states
31
+ before_validation :can_has_state_triggers
32
+ validate :can_has_valid_state_machines
33
+ after_save :can_has_deferred_state_triggers
34
+ end
35
+
36
+
37
+ private
38
+
39
+ def can_has_initial_states
40
+ state_machines.each do |(column, sm)|
41
+ if send(column).blank?
42
+ send("#{column}=", sm.initial_state)
43
+ end
44
+ end
45
+ end
46
+
47
+ def can_has_state_triggers
48
+ # skip triggers if any state machine isn't valid
49
+ return if can_has_state_errors.any?
50
+
51
+ @triggers_called ||= {}
52
+ state_machines.each do |(column, sm)|
53
+ from, to = send("#{column}_was"), send(column)
54
+ next if from == to
55
+
56
+ # skip triggers if they've already been called for this from/to transition
57
+ next if @triggers_called[column] == [from, to]
58
+
59
+ sm.trigger(self, from, to)
60
+
61
+ # record that triggers were called
62
+ @triggers_called[column] = [from, to]
63
+ end
64
+ end
65
+
66
+ def can_has_deferred_state_triggers
67
+ state_machines.each do |(column, sm)|
68
+ # clear record of called triggers
69
+ @triggers_called[column] = nil
70
+
71
+ from, to = send("#{column}_was"), send(column)
72
+ next if from == to
73
+ sm.trigger(self, from, to, :deferred)
74
+ end
75
+ end
76
+
77
+ def can_has_state_errors
78
+ err = []
79
+ state_machines.each do |(column, sm)|
80
+ from, to = send("#{column}_was"), send(column)
81
+ next if from == to
82
+ if !sm.known?(to)
83
+ err << [column, "is not a known state"]
84
+ elsif !sm.allow?(self, to) #state_machine_allow?(column, to)
85
+ err << [column, sm.message(to) % {:from=>from, :to=>to}]
86
+ end
87
+ end
88
+ err
89
+ end
90
+
91
+ def can_has_valid_state_machines
92
+ can_has_state_errors.each do |(column, msg)|
93
+ errors.add column, msg
94
+ end
95
+ end
96
+
97
+ def state_machine_allow?(column, to)
98
+ sm = state_machines.detect{|(col, stm)| col == column}
99
+ raise("Unknown state machine #{column}") unless sm
100
+ sm[1].allow?(self, to)
101
+ end
102
+
103
+ end
104
+ end
@@ -0,0 +1,11 @@
1
+ module CanHasState
2
+ class Railtie < Rails::Railtie
3
+
4
+ initializer "can_has_state" do |app|
5
+ ActiveSupport.on_load(:active_record) do
6
+ ActiveRecord::Base.send :include, CanHasState::Machine
7
+ end
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module CanHasState
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,7 @@
1
+ require 'active_support'
2
+ require 'active_model'
3
+
4
+ require 'can_has_state/definition'
5
+ require 'can_has_state/machine'
6
+
7
+ require 'can_has_state/railtie' if defined?(Rails)
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :can_has_state do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,7 @@
1
+ require 'test_helper'
2
+
3
+ class CanHasStateTest < ActiveSupport::TestCase
4
+ test "truth" do
5
+ assert_kind_of Module, CanHasState
6
+ end
7
+ end