laminar 0.3.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.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +35 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +17 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +13 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +362 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/laminar.gemspec +42 -0
- data/lib/laminar.rb +13 -0
- data/lib/laminar/callbacks.rb +53 -0
- data/lib/laminar/context.rb +45 -0
- data/lib/laminar/flow.rb +148 -0
- data/lib/laminar/flow/branch.rb +57 -0
- data/lib/laminar/flow/flow_error.rb +9 -0
- data/lib/laminar/flow/options_validator.rb +39 -0
- data/lib/laminar/flow/specification.rb +55 -0
- data/lib/laminar/flow/step.rb +87 -0
- data/lib/laminar/particle.rb +69 -0
- data/lib/laminar/particle_stopped.rb +13 -0
- data/lib/laminar/version.rb +5 -0
- metadata +140 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'laminar'
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require 'irb'
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/laminar.gemspec
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'laminar/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'laminar'
|
8
|
+
spec.version = Laminar::VERSION
|
9
|
+
spec.authors = ['Robert Lockerd']
|
10
|
+
spec.email = ['rmlockerd@gmail.com']
|
11
|
+
|
12
|
+
spec.summary = 'Simple, composable business objects & workflow'
|
13
|
+
spec.homepage = 'https://github.com/rmlockerd/laminar'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set
|
17
|
+
# the 'allowed_push_host' to allow pushing to a single host or delete
|
18
|
+
# this section to allow pushing to any host.
|
19
|
+
# if spec.respond_to?(:metadata)
|
20
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
|
21
|
+
# else
|
22
|
+
# raise 'RubyGems 2.0 or newer is required to protect against ' \
|
23
|
+
# 'public gem pushes.'
|
24
|
+
# end
|
25
|
+
|
26
|
+
# Specify which files should be added to the gem when it is released.
|
27
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been
|
28
|
+
# added into git.
|
29
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
30
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
31
|
+
end
|
32
|
+
spec.bindir = 'exe'
|
33
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
34
|
+
spec.require_paths = ['lib']
|
35
|
+
|
36
|
+
spec.add_development_dependency 'activesupport', '>= 4.2'
|
37
|
+
|
38
|
+
spec.add_development_dependency 'bundler', '~> 1.16'
|
39
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
40
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
41
|
+
spec.add_development_dependency 'simplecov', '~> 0.16'
|
42
|
+
end
|
data/lib/laminar.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'laminar/version'
|
4
|
+
require 'laminar/callbacks'
|
5
|
+
require 'laminar/context'
|
6
|
+
require 'laminar/particle_stopped'
|
7
|
+
require 'laminar/particle'
|
8
|
+
require 'laminar/flow'
|
9
|
+
|
10
|
+
# Simple engine for executing flows of atomic
|
11
|
+
# logic operations.
|
12
|
+
module Laminar
|
13
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Laminar
|
4
|
+
module Callbacks
|
5
|
+
def self.included(klass)
|
6
|
+
klass.class_eval do
|
7
|
+
extend ClassMethods
|
8
|
+
include InstanceMethods
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Class methods and attributes.
|
13
|
+
module ClassMethods
|
14
|
+
def before(*args, &block)
|
15
|
+
before_list.concat(args)
|
16
|
+
before_list << block if block
|
17
|
+
end
|
18
|
+
alias before_call before
|
19
|
+
|
20
|
+
def after(*args, &block)
|
21
|
+
after_list.concat(args)
|
22
|
+
after_list << block if block
|
23
|
+
end
|
24
|
+
alias after_call after
|
25
|
+
|
26
|
+
def before_list
|
27
|
+
@before_list ||= []
|
28
|
+
end
|
29
|
+
|
30
|
+
def after_list
|
31
|
+
@after_list ||= []
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Additional instance methods
|
36
|
+
module InstanceMethods
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def run_before_callbacks
|
41
|
+
run_callbacks(self.class.before_list)
|
42
|
+
end
|
43
|
+
|
44
|
+
def run_after_callbacks
|
45
|
+
run_callbacks(self.class.after_list)
|
46
|
+
end
|
47
|
+
|
48
|
+
def run_callbacks(list)
|
49
|
+
list.each { |cb| cb.is_a?(Symbol) ? send(cb) : instance_exec(&cb) }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Laminar
|
4
|
+
# The environment and state of a particle (or flow) invocation. The
|
5
|
+
# context provides data required for a particle to do its job. A particle can
|
6
|
+
# modify the context during execution to return results, errors, etc.
|
7
|
+
class Context < Hash
|
8
|
+
def self.build(context = {})
|
9
|
+
case context
|
10
|
+
when self
|
11
|
+
context
|
12
|
+
else
|
13
|
+
new.merge!(context || {})
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@halted = false
|
19
|
+
@failed = false
|
20
|
+
end
|
21
|
+
|
22
|
+
def success?
|
23
|
+
!failed?
|
24
|
+
end
|
25
|
+
|
26
|
+
def failed?
|
27
|
+
@failed
|
28
|
+
end
|
29
|
+
|
30
|
+
def halted?
|
31
|
+
@halted
|
32
|
+
end
|
33
|
+
|
34
|
+
def halt!(context = {})
|
35
|
+
@halted = true
|
36
|
+
merge!(context)
|
37
|
+
raise ParticleStopped, self
|
38
|
+
end
|
39
|
+
|
40
|
+
def fail!(context = {})
|
41
|
+
@failed = true
|
42
|
+
halt!(context)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/laminar/flow.rb
ADDED
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'laminar/flow/branch'
|
4
|
+
require 'laminar/flow/flow_error'
|
5
|
+
require 'laminar/flow/step'
|
6
|
+
require 'laminar/flow/specification'
|
7
|
+
|
8
|
+
module Laminar
|
9
|
+
# Implements a DSL for defining a chain of Particles. Each step (particle)
|
10
|
+
# contributes to an overall answer/outcome via a shared context.
|
11
|
+
#
|
12
|
+
# Simple branching and looping is supported via conditional jumps.
|
13
|
+
#
|
14
|
+
# The most basic flow is a simple set of steps executed sequentially.
|
15
|
+
# @example
|
16
|
+
# flow do
|
17
|
+
# step :first
|
18
|
+
# step :then_me
|
19
|
+
# step :last_step
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# Each step symbol names a class that includes Laminar::Particle. The call
|
23
|
+
# method specifies keyword arguments that the flow uses to determine which
|
24
|
+
# parts of the execution context to pass to the step.
|
25
|
+
#
|
26
|
+
# By default, the flow uses the step label as the implementation particle
|
27
|
+
# name. You can use the class directive to specify an alternate class
|
28
|
+
# name. This can be a String or Symbol. Very useful when your particles
|
29
|
+
# are organised into modules.
|
30
|
+
# @example
|
31
|
+
# flow do
|
32
|
+
# step :first
|
33
|
+
# step :then_me, class: :impl_class
|
34
|
+
# step :third, class: 'MyModule::DoSomething'
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# Branching is implemented via the goto directive. These directives are
|
38
|
+
# evaluated immediately following the execution of a step.
|
39
|
+
#
|
40
|
+
# @example
|
41
|
+
# flow do
|
42
|
+
# step :first do
|
43
|
+
# goto :last_step, if: :should_i?
|
44
|
+
# end
|
45
|
+
# step :then_me
|
46
|
+
# step :do_something
|
47
|
+
# step :last_step
|
48
|
+
# end
|
49
|
+
#
|
50
|
+
# In the previous example, execution will pass to last_step is the supplied
|
51
|
+
# method should_i? (on the flow instance) returns true. If no branch
|
52
|
+
# satisfies its conditions, execution will fall through to the next step.
|
53
|
+
#
|
54
|
+
# A step can have
|
55
|
+
# muluple goto directives; the flow will take the first branch that
|
56
|
+
# it finds that satisfies its specified condition (if any).
|
57
|
+
# @example
|
58
|
+
# flow do
|
59
|
+
# step :first do
|
60
|
+
# goto :last_step, if: :should_i?
|
61
|
+
# goto :do_something, unless: :another?
|
62
|
+
# end
|
63
|
+
# step :then_me
|
64
|
+
# step :do_something
|
65
|
+
# step :last_step
|
66
|
+
# end
|
67
|
+
#
|
68
|
+
# You can use the special goto tag :endflow to conditionally teriminate
|
69
|
+
# the flow.
|
70
|
+
# @example
|
71
|
+
# flow do
|
72
|
+
# step check_policy do
|
73
|
+
# goto :endflow, if :failed_policy?
|
74
|
+
# end
|
75
|
+
# end
|
76
|
+
#
|
77
|
+
module Flow
|
78
|
+
def self.included(base)
|
79
|
+
base.class_eval do
|
80
|
+
include Particle
|
81
|
+
extend ClassMethods
|
82
|
+
include InstanceMethods
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Add class methods and attributes.
|
87
|
+
module ClassMethods
|
88
|
+
# @!attribute [r] flowspec
|
89
|
+
# @return [FlowSpec] specification of the class' ruleflow.
|
90
|
+
attr_reader :flowspec
|
91
|
+
|
92
|
+
# Entry point for defining a flow.
|
93
|
+
def flow(args = {}, &block)
|
94
|
+
@flowspec = Specification.new(args, &block)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Add instance methods and attributes
|
99
|
+
module InstanceMethods
|
100
|
+
# @return [FlowSpec] the flow specification for the class.
|
101
|
+
def flowspec
|
102
|
+
self.class.flowspec
|
103
|
+
end
|
104
|
+
|
105
|
+
# Initiates evaluation of the flow.
|
106
|
+
# @param object the context/input on which the flow will operate. This
|
107
|
+
# is usually a Hash but in simple cases can be a single object. The
|
108
|
+
# implementing flow class should provide a #context_valid? method
|
109
|
+
# that returns true is the given context contains the minimum required
|
110
|
+
# information.
|
111
|
+
def call(*)
|
112
|
+
return context if flowspec.nil?
|
113
|
+
|
114
|
+
step = flowspec.steps[flowspec.first_step]
|
115
|
+
loop do
|
116
|
+
break unless invoke_step(step)
|
117
|
+
|
118
|
+
step = next_step(step)
|
119
|
+
end
|
120
|
+
context
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
def invoke_step(step)
|
126
|
+
return if step.nil?
|
127
|
+
run_callbacks(flowspec.before_each_callbacks)
|
128
|
+
run_callbacks(step.before_callbacks)
|
129
|
+
step.particle.call!(context)
|
130
|
+
run_callbacks(step.after_callbacks)
|
131
|
+
run_callbacks(flowspec.after_each_callbacks)
|
132
|
+
!context.halted?
|
133
|
+
end
|
134
|
+
|
135
|
+
# Given a step, returns the next step that satisfies the
|
136
|
+
# execution/branch conditions.
|
137
|
+
def next_step(current)
|
138
|
+
next_name = current.next_step_name(self)
|
139
|
+
return nil unless next_name && next_name != :endflow
|
140
|
+
unless flowspec.steps.key?(next_name)
|
141
|
+
raise FlowError, "No rule with name or alias of #{next_name}"
|
142
|
+
end
|
143
|
+
|
144
|
+
flowspec.steps[next_name]
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'laminar/flow/options_validator'
|
4
|
+
|
5
|
+
module Laminar
|
6
|
+
module Flow
|
7
|
+
# Specification for a target rule transition.
|
8
|
+
class Branch
|
9
|
+
include OptionsValidator
|
10
|
+
|
11
|
+
valid_options %i[if unless].freeze
|
12
|
+
|
13
|
+
# @!attribute name
|
14
|
+
# @return target rule to branch to
|
15
|
+
#
|
16
|
+
# @!attribute condition
|
17
|
+
# @return the branch condition (method name symbol or Proc/lambda)
|
18
|
+
attr_accessor :name, :condition, :condition_type
|
19
|
+
|
20
|
+
def initialize(name, options = {})
|
21
|
+
unless name.class.method_defined?(:to_sym)
|
22
|
+
raise ArgumentError, 'invalid name'
|
23
|
+
end
|
24
|
+
|
25
|
+
validate_options(options)
|
26
|
+
@name = name.to_sym
|
27
|
+
define_condition(options)
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param [RuleBase] context a given rule implementation
|
31
|
+
#
|
32
|
+
# @return [Boolean] true if condition is satisfied in the context.
|
33
|
+
def meets_condition?(target)
|
34
|
+
return true if condition.nil?
|
35
|
+
|
36
|
+
result = run_condition(target)
|
37
|
+
condition_type == :if ? result : !result
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def run_condition(target)
|
43
|
+
target.send(@condition)
|
44
|
+
end
|
45
|
+
|
46
|
+
def define_condition(options)
|
47
|
+
@condition_type = (options.keys & %i[if unless]).first
|
48
|
+
return if @condition_type.nil?
|
49
|
+
|
50
|
+
@condition = options[@condition_type]
|
51
|
+
return if @condition.nil? || @condition.is_a?(Symbol)
|
52
|
+
|
53
|
+
raise TypeError, 'condition must be a method (symbol).'
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Laminar
|
4
|
+
module Flow
|
5
|
+
# Concern that adds ability to validate arbitrary directive options.
|
6
|
+
module OptionsValidator
|
7
|
+
def self.included(base)
|
8
|
+
base.class_eval do
|
9
|
+
extend ClassMethods
|
10
|
+
include InstanceMethods
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# Add class methods and attributes.
|
15
|
+
module ClassMethods
|
16
|
+
attr_reader :option_list
|
17
|
+
|
18
|
+
# Entry point for defining a flow.
|
19
|
+
def valid_options(*args)
|
20
|
+
@option_list = args.flatten
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Add instance methods and attributes
|
25
|
+
module InstanceMethods
|
26
|
+
def validate_options(options)
|
27
|
+
valid = self.class.option_list
|
28
|
+
options.each_key do |k|
|
29
|
+
next if valid.include?(k)
|
30
|
+
|
31
|
+
raise ArgumentError,
|
32
|
+
"Unknown key: #{k.inspect}. Valid keys are: "\
|
33
|
+
"#{valid.map(&:inspect).join(', ')}."
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|