rule 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/README.rdoc +64 -0
- data/Rakefile +2 -0
- data/lib/rule.rb +18 -0
- data/lib/rule/active_record_extensions.rb +12 -0
- data/lib/rule/allow.rb +10 -0
- data/lib/rule/assertions.rb +21 -0
- data/lib/rule/base.rb +21 -0
- data/lib/rule/disallow.rb +10 -0
- data/lib/rule/engine/active_record.rb +27 -0
- data/lib/rule/engine/base.rb +63 -0
- data/lib/rule/engine/invalid_transition.rb +3 -0
- data/lib/rule/engine/state.rb +46 -0
- data/lib/rule/engine/transition.rb +59 -0
- data/lib/rule/railtie.rb +9 -0
- data/lib/rule/version.rb +3 -0
- data/rule.gemspec +19 -0
- metadata +63 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.rdoc
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
= Rule
|
2
|
+
|
3
|
+
== What is it?
|
4
|
+
|
5
|
+
Rule is a state machine / rule engine. It does things.
|
6
|
+
|
7
|
+
== How do I get started?
|
8
|
+
|
9
|
+
1. Add it to your gemfile.
|
10
|
+
|
11
|
+
gem "rule", :git => "https://github.com/innovatis/rule"
|
12
|
+
|
13
|
+
2. Add a "state" column and this code to your model:
|
14
|
+
|
15
|
+
rule_engine MyAwesomeRuleEngine
|
16
|
+
|
17
|
+
3. create a file at app/rule_engines/my_awesome_rule_engine.rb
|
18
|
+
|
19
|
+
class MyAwesomeRuleEngine < Rule::Engine::Base
|
20
|
+
|
21
|
+
state :new
|
22
|
+
state :ongoing
|
23
|
+
state :closed
|
24
|
+
|
25
|
+
initial_state :new
|
26
|
+
|
27
|
+
terminal_state :closed
|
28
|
+
|
29
|
+
transition :new, :ongoing
|
30
|
+
validate IsInProgressRule
|
31
|
+
end
|
32
|
+
|
33
|
+
transition :ongoing, :closed
|
34
|
+
assert_presence_of object.closed_at, "Closed At"
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
4. Create a file at app/rules/is_in_progress_rule.rb
|
40
|
+
|
41
|
+
class IsInProgressRule < Rule::Base
|
42
|
+
def validate
|
43
|
+
@object.in_progress.present?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
5. Set your application up to run the rules.
|
48
|
+
|
49
|
+
# wherever it makes sense...
|
50
|
+
my_object.run_rules # advances state as far as possible and automatically saves
|
51
|
+
|
52
|
+
== Explain all that stuff.
|
53
|
+
|
54
|
+
Okay.
|
55
|
+
|
56
|
+
The Rule::Engine::Base subclass defines states and transitions. Each transition block contains a number of assertions and an optional priority. If any of the assertions does not pass, the state will not follow this transition. The priority decides which transition to use in the case of two possible transitions out of the current state both being valid. Use it by specifying "priority :high" or "priority :low" in the transition block.
|
57
|
+
|
58
|
+
It often makes sense to bundle a group of assertions into a Rule class. These are defined in app/rules, and have a `validate` method containing assertions. In the future, rules will also have callbacks that fire when the state is advanced through a transition including that rule.
|
59
|
+
|
60
|
+
If this didn't answer your questions, throw things at Burke until he explains.
|
61
|
+
|
62
|
+
== Copyright
|
63
|
+
|
64
|
+
Copyright (c) 2011 Burke Libbey / Innovatis. MIT License.
|
data/Rakefile
ADDED
data/lib/rule.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
module Rule
|
2
|
+
end
|
3
|
+
|
4
|
+
path = File.dirname(__FILE__)
|
5
|
+
$:.unshift(path) unless $:.include?(path)
|
6
|
+
|
7
|
+
require 'rule/assertions'
|
8
|
+
require 'rule/base'
|
9
|
+
require 'rule/allow'
|
10
|
+
require 'rule/disallow'
|
11
|
+
require 'rule/engine/base'
|
12
|
+
require 'rule/engine/invalid_transition'
|
13
|
+
require 'rule/engine/state'
|
14
|
+
require 'rule/engine/transition'
|
15
|
+
|
16
|
+
require 'rule/active_record_extensions'
|
17
|
+
require 'rule/engine/active_record'
|
18
|
+
require 'rule/railtie'
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class ActiveRecord::Base
|
2
|
+
def self.rule_engine(engine, opts={})
|
3
|
+
include(Rule::Engine::ActiveRecord)
|
4
|
+
column = opts[:column] || :state
|
5
|
+
if opts.delete(:before_save)
|
6
|
+
self.before_save :run_rules
|
7
|
+
end
|
8
|
+
|
9
|
+
@__rule_engine = engine.new(column)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
data/lib/rule/allow.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Rule
|
2
|
+
module Assertions
|
3
|
+
def assert(assertion, message)
|
4
|
+
unless assertion
|
5
|
+
add_error message
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def assert_presence_of(thing, name)
|
10
|
+
unless thing.present?
|
11
|
+
add_error "#{name} must be provided"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def assert_absence_of(thing, name)
|
16
|
+
unless thing.blank?
|
17
|
+
add_error "#{name} must be blank"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/rule/base.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Rule
|
2
|
+
|
3
|
+
class Base
|
4
|
+
attr_reader :object
|
5
|
+
def initialize(object)
|
6
|
+
@object = object
|
7
|
+
@errors = []
|
8
|
+
end
|
9
|
+
|
10
|
+
def add_error(error)
|
11
|
+
@errors << error
|
12
|
+
end
|
13
|
+
|
14
|
+
def pass?
|
15
|
+
validate != false and @errors.none?
|
16
|
+
end
|
17
|
+
|
18
|
+
include Rule::Assertions
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module Rule
|
4
|
+
module Engine
|
5
|
+
module ActiveRecord
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
|
10
|
+
end
|
11
|
+
|
12
|
+
module InstanceMethods
|
13
|
+
def run_rules
|
14
|
+
engine = self.class.instance_variable_get("@__rule_engine")
|
15
|
+
engine.run!(self)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Rule
|
2
|
+
module Engine
|
3
|
+
class Base
|
4
|
+
|
5
|
+
def initialize(column)
|
6
|
+
@column = column
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.state(state)
|
10
|
+
find_or_create_state(state)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.initial_state(state)
|
14
|
+
@initial_state = find_or_create_state(state)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.terminal_state(state)
|
18
|
+
@terminal_states ||= []
|
19
|
+
@terminal_states << find_or_create_state(state)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.transition(from, to, &blk)
|
23
|
+
from_state = find_state!(from)
|
24
|
+
to_state = find_state!(to)
|
25
|
+
|
26
|
+
from_state.add_transition(to_state, blk)
|
27
|
+
end
|
28
|
+
|
29
|
+
def run!(object)
|
30
|
+
# state = self.class.find_state(object.send(@column).try(:to_sym)) || self.class.instance_variable_get("@initial_state")
|
31
|
+
# Revalidate from initial state each time. This is more along the lines of the behaviour dave wants.
|
32
|
+
state = self.class.instance_variable_get("@initial_state")
|
33
|
+
loop do
|
34
|
+
prev_state = state
|
35
|
+
state = prev_state.next_state(object)
|
36
|
+
break if prev_state == state
|
37
|
+
end
|
38
|
+
|
39
|
+
object.send("#{@column}=", state.name)
|
40
|
+
end
|
41
|
+
|
42
|
+
private #################################################################
|
43
|
+
|
44
|
+
def self.find_or_create_state(name)
|
45
|
+
@states ||= []
|
46
|
+
unless state = @states.find { |state| state.name == name }
|
47
|
+
state = Rule::Engine::State.new(name)
|
48
|
+
@states << state
|
49
|
+
end
|
50
|
+
state
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.find_state(name)
|
54
|
+
@states.find { |state| state.name == name }
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.find_state!(name)
|
58
|
+
find_state(name) or raise "State #{name} not declared"
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Rule
|
2
|
+
module Engine
|
3
|
+
class State
|
4
|
+
|
5
|
+
attr_reader :name
|
6
|
+
|
7
|
+
def initialize(name)
|
8
|
+
@name = name
|
9
|
+
@transitions = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def add_transition(to, blk)
|
13
|
+
@transitions << Transition.new(self, to, blk)
|
14
|
+
end
|
15
|
+
|
16
|
+
def valid_transitions(object)
|
17
|
+
@transitions.map { |transition|
|
18
|
+
begin
|
19
|
+
transition.run!(object)
|
20
|
+
rescue Rule::Engine::InvalidTransition
|
21
|
+
nil
|
22
|
+
else
|
23
|
+
transition
|
24
|
+
end
|
25
|
+
}.compact
|
26
|
+
end
|
27
|
+
|
28
|
+
def next_state(object)
|
29
|
+
transitions = valid_transitions(object)
|
30
|
+
if transitions.any?
|
31
|
+
return transitions.
|
32
|
+
sort_by{|transition| transition.instance_variable_get("@priority")}.
|
33
|
+
last.
|
34
|
+
to_state
|
35
|
+
else
|
36
|
+
return self
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Rule
|
2
|
+
module Engine
|
3
|
+
class Transition
|
4
|
+
attr_reader :from, :to, :action, :object
|
5
|
+
alias_method :to_state, :to
|
6
|
+
alias_method :from_state, :from
|
7
|
+
|
8
|
+
PRIORITIES = {
|
9
|
+
:min => 0,
|
10
|
+
:very_low => 1,
|
11
|
+
:low => 3,
|
12
|
+
:normal => 5,
|
13
|
+
:high => 7,
|
14
|
+
:very_high => 9,
|
15
|
+
:max => 10
|
16
|
+
}
|
17
|
+
|
18
|
+
def initialize(from, to, action)
|
19
|
+
@from = from
|
20
|
+
@to = to
|
21
|
+
@action = action
|
22
|
+
@priority = PRIORITIES[:normal]
|
23
|
+
end
|
24
|
+
|
25
|
+
def run!(object)
|
26
|
+
@object = object
|
27
|
+
action.bind(self).call
|
28
|
+
end
|
29
|
+
|
30
|
+
def add_error(error)
|
31
|
+
raise Rule::Engine::InvalidTransition
|
32
|
+
end
|
33
|
+
|
34
|
+
include Rule::Assertions
|
35
|
+
|
36
|
+
def validate(rule_klass)
|
37
|
+
unless rule_klass.new(@object).pass?
|
38
|
+
raise Rule::Engine::InvalidTransition
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def priority(priority)
|
43
|
+
case priority
|
44
|
+
when Numeric
|
45
|
+
@priority = priority
|
46
|
+
when Symbol
|
47
|
+
@priority = PRIORITIES[:normal]
|
48
|
+
else
|
49
|
+
raise "Invalid priority"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
|
59
|
+
|
data/lib/rule/railtie.rb
ADDED
data/lib/rule/version.rb
ADDED
data/rule.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "rule/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "rule"
|
7
|
+
s.version = Rule::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Burke Libbey"]
|
10
|
+
s.email = ["burke@burkelibbey.org"]
|
11
|
+
s.homepage = ""
|
12
|
+
s.summary = %q{Rules on Rails}
|
13
|
+
s.description = %q{Rule Engine sort of thing for Rails.}
|
14
|
+
|
15
|
+
s.files = `git ls-files`.split("\n")
|
16
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
17
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
|
+
s.require_paths = ["lib"]
|
19
|
+
end
|
metadata
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rule
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.2
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Burke Libbey
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-10-21 00:00:00.000000000Z
|
13
|
+
dependencies: []
|
14
|
+
description: Rule Engine sort of thing for Rails.
|
15
|
+
email:
|
16
|
+
- burke@burkelibbey.org
|
17
|
+
executables: []
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- .gitignore
|
22
|
+
- Gemfile
|
23
|
+
- README.rdoc
|
24
|
+
- Rakefile
|
25
|
+
- lib/rule.rb
|
26
|
+
- lib/rule/active_record_extensions.rb
|
27
|
+
- lib/rule/allow.rb
|
28
|
+
- lib/rule/assertions.rb
|
29
|
+
- lib/rule/base.rb
|
30
|
+
- lib/rule/disallow.rb
|
31
|
+
- lib/rule/engine/active_record.rb
|
32
|
+
- lib/rule/engine/base.rb
|
33
|
+
- lib/rule/engine/invalid_transition.rb
|
34
|
+
- lib/rule/engine/state.rb
|
35
|
+
- lib/rule/engine/transition.rb
|
36
|
+
- lib/rule/railtie.rb
|
37
|
+
- lib/rule/version.rb
|
38
|
+
- rule.gemspec
|
39
|
+
homepage: ''
|
40
|
+
licenses: []
|
41
|
+
post_install_message:
|
42
|
+
rdoc_options: []
|
43
|
+
require_paths:
|
44
|
+
- lib
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
46
|
+
none: false
|
47
|
+
requirements:
|
48
|
+
- - ! '>='
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: '0'
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ! '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
requirements: []
|
58
|
+
rubyforge_project:
|
59
|
+
rubygems_version: 1.8.6
|
60
|
+
signing_key:
|
61
|
+
specification_version: 3
|
62
|
+
summary: Rules on Rails
|
63
|
+
test_files: []
|