laminar 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Laminar
4
+ module Flow
5
+ # Exception to immediately terminate rule processing
6
+ class FlowError < StandardError
7
+ end
8
+ end
9
+ 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