can_has_state 0.2.0

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