edge-state-machine 0.0.1

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.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ --colour
2
+ #--drb
3
+ --format documentation
4
+ #--fail-fast
data/.travis.yml ADDED
@@ -0,0 +1 @@
1
+ rvm: 1.9.2
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in edge-state-machine.gemspec
4
+ gemspec
data/README.rdoc ADDED
@@ -0,0 +1,63 @@
1
+ = Travis Build Status
2
+
3
+ {<img src="https://secure.travis-ci.org/danpersa/edge-state-machine.png"/>}[http://travis-ci.org/danpersa/edge-state-machine]
4
+
5
+ = Edge State Machine
6
+
7
+ The gem is based on Rick Olson's code of ActiveModel::StateMachine,
8
+ axed from ActiveModel in {this
9
+ commit}[http://github.com/rails/rails/commit/db49c706b62e7ea2ab93f05399dbfddf5087ee0c].
10
+
11
+ And on Krzysiek Heród's gem, {Transitions}[https://github.com/netizer/transitions], which added Mongoid support.
12
+
13
+ == Installation
14
+
15
+ If you're using Rails + ActiveRecord + Bundler
16
+
17
+ # in your Gemfile
18
+ gem "edge-state-machine", :require => ["edge-state-machine", "active_record/edge-state-machine"]
19
+
20
+ # in your AR models that will use the state machine
21
+ include ::EdgeStateMachine
22
+ include ActiveRecord::EdgeStateMachine
23
+
24
+ state_machine do
25
+ state :available # first one is initial state
26
+ state :out_of_stock
27
+ state :discontinue
28
+
29
+ event :discontinue do
30
+ transitions :to => :discontinue, :from => [:available, :out_of_stock], :on_transition => :do_discontinue
31
+ end
32
+ event :out_of_stock do
33
+ transitions :to => :out_of_stock, :from => [:available, :discontinue]
34
+ end
35
+ event :available do
36
+ transitions :to => :available, :from => [:out_of_stock], :on_transition => :send_alerts
37
+ end
38
+ end
39
+
40
+ If you're using Rails + Mongoid + Bundler
41
+
42
+ # in your Gemfile
43
+ gem "edge-state-machine", :require => ["edge-state-machine", "mongoid/edge-state-machine"]
44
+
45
+ # in your AR models that will use the state machine
46
+ include ::EdgeStateMachine
47
+ include Mongoid::EdgeStateMachine
48
+
49
+ state_machine do
50
+ state :available # first one is initial state
51
+ state :out_of_stock
52
+ state :discontinue
53
+
54
+ event :discontinue do
55
+ transitions :to => :discontinue, :from => [:available, :out_of_stock], :on_transition => :do_discontinue
56
+ end
57
+ event :out_of_stock do
58
+ transitions :to => :out_of_stock, :from => [:available, :discontinue]
59
+ end
60
+ event :available do
61
+ transitions :to => :available, :from => [:out_of_stock], :on_transition => :send_alerts
62
+ end
63
+ end
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ require 'bundler/gem_tasks'
4
+
5
+
6
+ Bundler::GemHelper.install_tasks
7
+ Bundler.setup
8
+
9
+ require 'rake'
10
+
11
+ begin
12
+ require 'rspec/core/rake_task'
13
+ desc 'Run RSpecs to confirm that all functionality is working as expected'
14
+ RSpec::Core::RakeTask.new('spec') do |t|
15
+ t.pattern = 'spec/**/*_spec.rb'
16
+ end
17
+ task :default => :spec
18
+ rescue LoadError
19
+ puts "Hiding spec tasks because RSpec is not available"
20
+ end
@@ -0,0 +1,31 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "edge-state-machine/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "edge-state-machine"
7
+ s.version = EdgeStateMachine::VERSION
8
+ s.authors = ["Dan Persa"]
9
+ s.email = ["dan.persa@gmail.com"]
10
+ s.homepage = "http://github.com/danpersa/edge-state-machine"
11
+ s.summary = %q{State machine extracted from ActiveModel}
12
+ s.description = %q{Lightweight state machine extracted from ActiveModel}
13
+
14
+ s.rubyforge_project = "edge-state-machine"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+
22
+ # specify any dependencies here; for example:
23
+ s.add_development_dependency 'rspec', '~> 2.6'
24
+ s.add_development_dependency 'rake'
25
+ s.add_development_dependency 'mongoid'
26
+ s.add_development_dependency 'bson_ext'
27
+ s.add_development_dependency 'sqlite3-ruby'
28
+ s.add_development_dependency 'activerecord'
29
+
30
+ # s.add_runtime_dependency "rest-client"
31
+ end
@@ -0,0 +1,34 @@
1
+ module ActiveRecord
2
+ module EdgeStateMachine
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ::EdgeStateMachine
7
+ after_initialize :set_initial_state
8
+ validates_presence_of :state
9
+ validate :state_inclusion
10
+ end
11
+
12
+ protected
13
+
14
+ def write_state(state_machine, state)
15
+ self.state = state.to_s
16
+ save!
17
+ end
18
+
19
+ def read_state(state_machine)
20
+ self.state.to_sym
21
+ end
22
+
23
+ def set_initial_state
24
+ self.state ||= self.class.state_machine.initial_state.to_s
25
+ end
26
+
27
+ def state_inclusion
28
+ unless self.class.state_machine.states.map{|s| s.name.to_s }.include?(self.state.to_s)
29
+ self.errors.add(:state, :inclusion, :value => self.state)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,107 @@
1
+ module EdgeStateMachine
2
+ class Event
3
+ attr_reader :name, :success, :timestamp
4
+
5
+ def initialize(machine, name, options = {}, &block)
6
+ @machine, @name, @transitions = machine, name, []
7
+ if machine
8
+ machine.klass.send(:define_method, "#{name}!") do |*args|
9
+ machine.fire_event(name, self, true, *args)
10
+ end
11
+
12
+ machine.klass.send(:define_method, name.to_s) do |*args|
13
+ machine.fire_event(name, self, false, *args)
14
+ end
15
+ end
16
+ update(options, &block)
17
+ end
18
+
19
+ def fire(obj, to_state = nil, *args)
20
+ transitions = @transitions.select { |t| t.from == obj.current_state(@machine ? @machine.name : nil) }
21
+ raise InvalidTransition if transitions.size == 0
22
+
23
+ next_state = nil
24
+ transitions.each do |transition|
25
+ next if to_state && !Array(transition.to).include?(to_state)
26
+ if transition.perform(obj)
27
+ next_state = to_state || Array(transition.to).first
28
+ transition.execute(obj, *args)
29
+ break
30
+ end
31
+ end
32
+ # Update timestamps on obj if a timestamp has been defined
33
+ update_event_timestamp(obj, next_state) if timestamp_defined?
34
+ next_state
35
+ end
36
+
37
+ def transitions_from_state?(state)
38
+ @transitions.any? { |t| t.from? state }
39
+ end
40
+
41
+ def ==(event)
42
+ if event.is_a? Symbol
43
+ name == event
44
+ else
45
+ name == event.name
46
+ end
47
+ end
48
+
49
+ # Has the timestamp option been specified for this event?
50
+ def timestamp_defined?
51
+ !@timestamp.nil?
52
+ end
53
+
54
+ def update(options = {}, &block)
55
+ @success = options[:success] if options.key?(:success)
56
+ self.timestamp = options[:timestamp] if options[:timestamp]
57
+ instance_eval(&block) if block
58
+ self
59
+ end
60
+
61
+ # update the timestamp attribute on obj
62
+ def update_event_timestamp(obj, next_state)
63
+ obj.send "#{timestamp_attribute_name(obj, next_state)}=", Time.now
64
+ end
65
+
66
+ # Set the timestamp attribute.
67
+ # @raise [ArgumentError] timestamp should be either a String, Symbol or true
68
+ def timestamp=(value)
69
+ case value
70
+ when String, Symbol, TrueClass
71
+ @timestamp = value
72
+ else
73
+ raise ArgumentError, "timestamp must be either: true, a String or a Symbol"
74
+ end
75
+ end
76
+
77
+
78
+ private
79
+
80
+ # Returns the name of the timestamp attribute for this event
81
+ # If the timestamp was simply true it returns the default_timestamp_name
82
+ # otherwise, returns the user-specified timestamp name
83
+ def timestamp_attribute_name(obj, next_state)
84
+ timestamp == true ? default_timestamp_name(obj, next_state) : @timestamp
85
+ end
86
+
87
+ # If @timestamp is true, try a default timestamp name
88
+ def default_timestamp_name(obj, next_state)
89
+ at_name = "%s_at" % next_state
90
+ on_name = "%s_on" % next_state
91
+ case
92
+ when obj.respond_to?(at_name) then at_name
93
+ when obj.respond_to?(on_name) then on_name
94
+ else
95
+ raise NoMethodError, "Couldn't find a suitable timestamp field for event: #{@name}.
96
+ Please define #{at_name} or #{on_name} in #{obj.class}"
97
+ end
98
+ end
99
+
100
+
101
+ def transitions(trans_opts)
102
+ Array(trans_opts[:from]).each do |s|
103
+ @transitions << StateTransition.new(trans_opts.merge({:from => s.to_sym}))
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,85 @@
1
+ module EdgeStateMachine
2
+ class Machine
3
+ attr_writer :initial_state
4
+ attr_accessor :states, :events, :state_index
5
+ attr_reader :klass, :name, :auto_scopes
6
+
7
+ def initialize(klass, name, options = {}, &block)
8
+ @klass, @name, @states, @state_index, @events = klass, name, [], {}, {}
9
+ update(options, &block)
10
+ end
11
+
12
+ def initial_state
13
+ @initial_state ||= (states.first ? states.first.name : nil)
14
+ end
15
+
16
+ def update(options = {}, &block)
17
+ @initial_state = options[:initial] if options.key?(:initial)
18
+ @auto_scopes = options[:auto_scopes]
19
+ instance_eval(&block) if block
20
+ include_scopes if @auto_scopes && defined?(ActiveRecord::Base) && @klass < ActiveRecord::Base
21
+ self
22
+ end
23
+
24
+ def fire_event(event, record, persist, *args)
25
+ state_index[record.current_state(@name)].call_action(:exit, record)
26
+ if new_state = @events[event].fire(record, nil, *args)
27
+ state_index[new_state].call_action(:enter, record)
28
+
29
+ if record.respond_to?(event_fired_callback)
30
+ record.send(event_fired_callback, record.current_state, new_state, event)
31
+ end
32
+
33
+ record.current_state(@name, new_state, persist)
34
+ record.send(@events[event].success) if @events[event].success
35
+ true
36
+ else
37
+ if record.respond_to?(event_failed_callback)
38
+ record.send(event_failed_callback, event)
39
+ end
40
+
41
+ false
42
+ end
43
+ end
44
+
45
+ def states_for_select
46
+ states.map { |st| [st.display_name, st.name.to_s] }
47
+ end
48
+
49
+ def events_for(state)
50
+ events = @events.values.select { |event| event.transitions_from_state?(state) }
51
+ events.map! { |event| event.name }
52
+ end
53
+
54
+ def current_state_variable
55
+ "@#{@name}_current_state"
56
+ end
57
+
58
+ private
59
+
60
+ def state(name, options = {})
61
+ @states << (state_index[name] ||= State.new(name, :machine => self)).update(options)
62
+ end
63
+
64
+ def event(name, options = {}, &block)
65
+ (@events[name] ||= Event.new(self, name)).update(options, &block)
66
+ end
67
+
68
+ def event_fired_callback
69
+ @event_fired_callback ||= (@name == :default ? '' : "#{@name}_") + 'event_fired'
70
+ end
71
+
72
+ def event_failed_callback
73
+ @event_failed_callback ||= (@name == :default ? '' : "#{@name}_") + 'event_failed'
74
+ end
75
+
76
+ def include_scopes
77
+ @states.each do |state|
78
+ state_name = state.name.to_s
79
+ raise InvalidMethodOverride if @klass.respond_to?(state_name)
80
+ @klass.scope state_name, @klass.where(:state => state_name)
81
+ end
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,45 @@
1
+ module EdgeStateMachine
2
+ class State
3
+ attr_reader :name, :options
4
+
5
+ def initialize(name, options = {})
6
+ @name = name
7
+ if machine = options.delete(:machine)
8
+ machine.klass.define_state_query_method(name)
9
+ end
10
+ update(options)
11
+ end
12
+
13
+ def ==(state)
14
+ if state.is_a? Symbol
15
+ name == state
16
+ else
17
+ name == state.name
18
+ end
19
+ end
20
+
21
+ def call_action(action, record)
22
+ action = @options[action]
23
+ case action
24
+ when Symbol, String
25
+ record.send(action)
26
+ when Proc
27
+ action.call(record)
28
+ end
29
+ end
30
+
31
+ def display_name
32
+ @display_name ||= name.to_s.gsub(/_/, ' ').capitalize
33
+ end
34
+
35
+ def for_select
36
+ [display_name, name.to_s]
37
+ end
38
+
39
+ def update(options = {})
40
+ @display_name = options.delete(:display) if options.key?(:display)
41
+ @options = options
42
+ self
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ module EdgeStateMachine
2
+ class StateTransition
3
+ attr_reader :from, :to, :options
4
+
5
+ def initialize(opts)
6
+ @from, @to, @guard, @on_transition = opts[:from], opts[:to], opts[:guard], opts[:on_transition]
7
+ @options = opts
8
+ end
9
+
10
+ def perform(obj)
11
+ case @guard
12
+ when Symbol, String
13
+ obj.send(@guard)
14
+ when Proc
15
+ @guard.call(obj)
16
+ else
17
+ true
18
+ end
19
+ end
20
+
21
+ def execute(obj, *args)
22
+ case @on_transition
23
+ when Symbol, String
24
+ obj.send(@on_transition, *args)
25
+ when Proc
26
+ @on_transition.call(obj, *args)
27
+ when Array
28
+ @on_transition.each do |callback|
29
+ # Yes, we're passing always the same parameters for each callback in here.
30
+ # We should probably drop args altogether in case we get an array.
31
+ obj.send(callback, *args)
32
+ end
33
+ else
34
+ # TODO We probably should check for this in the constructor and not that late.
35
+ raise ArgumentError, "You can only pass a Symbol, a String, a Proc or an Array to 'on_transition' - got #{@on_transition.class}." unless @on_transition.nil?
36
+ end
37
+ end
38
+
39
+ def ==(obj)
40
+ @from == obj.from && @to == obj.to
41
+ end
42
+
43
+ def from?(value)
44
+ @from == value
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module EdgeStateMachine
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,71 @@
1
+ require "edge-state-machine/event"
2
+ require "edge-state-machine/machine"
3
+ require "edge-state-machine/state"
4
+ require "edge-state-machine/state_transition"
5
+ require "edge-state-machine/version"
6
+
7
+ module EdgeStateMachine
8
+ class InvalidTransition < StandardError; end
9
+ class InvalidMethodOverride < StandardError; end
10
+
11
+ module ClassMethods
12
+ def inherited(klass)
13
+ super
14
+ klass.state_machines = state_machines
15
+ end
16
+
17
+ def state_machines
18
+ @state_machines ||= {}
19
+ end
20
+
21
+ def state_machines=(value)
22
+ @state_machines = value ? value.dup : nil
23
+ end
24
+
25
+ def state_machine(name = nil, options = {}, &block)
26
+ if name.is_a?(Hash)
27
+ options = name
28
+ name = nil
29
+ end
30
+ name ||= :default
31
+ state_machines[name] ||= Machine.new(self, name)
32
+ block ? state_machines[name].update(options, &block) : state_machines[name]
33
+ end
34
+
35
+ def define_state_query_method(state_name)
36
+ name = "#{state_name}?"
37
+ undef_method(name) if method_defined?(name)
38
+ class_eval "def #{name}; current_state.to_s == %(#{state_name}) end"
39
+ end
40
+ end
41
+
42
+ def self.included(base)
43
+ base.extend(ClassMethods)
44
+ end
45
+
46
+ def current_state(name = nil, new_state = nil, persist = false)
47
+ sm = self.class.state_machine(name)
48
+ ivar = sm.current_state_variable
49
+ if name && new_state
50
+ if persist && respond_to?(:write_state)
51
+ write_state(sm, new_state)
52
+ end
53
+
54
+ if respond_to?(:write_state_without_persistence)
55
+ write_state_without_persistence(sm, new_state)
56
+ end
57
+
58
+ instance_variable_set(ivar, new_state)
59
+ else
60
+ instance_variable_set(ivar, nil) unless instance_variable_defined?(ivar)
61
+ value = instance_variable_get(ivar)
62
+ return value if value
63
+
64
+ if respond_to?(:read_state)
65
+ value = instance_variable_set(ivar, read_state(sm))
66
+ end
67
+
68
+ value || sm.initial_state
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,34 @@
1
+ module Mongoid
2
+ module EdgeStateMachine
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ::EdgeStateMachine
7
+ after_initialize :set_initial_state
8
+ validates_presence_of :state
9
+ validate :state_inclusion
10
+ end
11
+
12
+ protected
13
+
14
+ def write_state(state_machine, state)
15
+ self.state = state.to_s
16
+ save!
17
+ end
18
+
19
+ def read_state(state_machine)
20
+ self.state.to_sym
21
+ end
22
+
23
+ def set_initial_state
24
+ self.state ||= self.class.state_machine.initial_state.to_s
25
+ end
26
+
27
+ def state_inclusion
28
+ unless self.class.state_machine.states.map{|s| s.name.to_s }.include?(self.state.to_s)
29
+ self.errors.add(:state, :inclusion, :value => self.state)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'active_record'
3
+ require 'active_support/core_ext/module/aliasing'
4
+ require 'migrations/create_orders'
5
+ require 'migrations/create_traffic_lights'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+
10
+ require 'active_record/edge-state-machine'