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.
@@ -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