railway_operation 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "railway_operation"
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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terminal-table'
4
+
5
+ require 'railway_operation/version'
6
+ require 'railway_operation/generic/ensured_access'
7
+ require 'railway_operation/generic/filled_matrix'
8
+ require 'railway_operation/generic/typed_array'
9
+ require 'railway_operation/stepper'
10
+ require 'railway_operation/strategy'
11
+ require 'railway_operation/surround'
12
+ require 'railway_operation/steps_array'
13
+ require 'railway_operation/operation'
14
+ require 'railway_operation/operator'
15
+ require 'railway_operation/info'
16
+
17
+ module RailwayOperation
18
+ def self.included(base)
19
+ base.extend Operator::ClassMethods
20
+ base.send :include, Operator::InstanceMethods
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailwayOperation
4
+ module Generic
5
+ # Ensures that the default value is available for use
6
+ # The problem with normal default values is that they are returned,
7
+ # they are not part of the actually collection.
8
+ #
9
+ # hash = Hash.new { [] }
10
+ # hash['a'] == []
11
+ #
12
+ # However, if you do the following
13
+ # hash['a'] << 2
14
+ # hash == {}
15
+ # hash['a'] != 2
16
+ #
17
+ # With this you can:
18
+ #
19
+ # ensured_hash = EnsuredAccess({}) { [] }
20
+ # ensured_hash['a'] << 2
21
+ # ensured_hash == { 'a' => 2 }
22
+ class EnsuredAccess < Delegator
23
+ def initialize(obj, default = nil, &block)
24
+ @obj = obj
25
+ @default = default || block
26
+ end
27
+
28
+ def __setobj__(obj)
29
+ @obj = obj
30
+ end
31
+
32
+ def __getobj__
33
+ @obj
34
+ end
35
+
36
+ def [](key)
37
+ @obj[key] ||= @default.respond_to?(:call) ? @default.call : @default
38
+ @obj[key]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailwayOperation
4
+ module Generic
5
+ class FilledMatrix
6
+ include Enumerable
7
+
8
+ def initialize(*rows, row_type: Array)
9
+ @row_type = row_type
10
+ @matrix = EnsuredAccess.new(@row_type.new(rows)) do
11
+ EnsuredAccess.new(@row_type.new)
12
+ end
13
+
14
+ ensure_rows_length_are_equal!
15
+ end
16
+
17
+ def [](row_index, column_index = nil)
18
+ if column_index
19
+ @matrix.__getobj__[row_index] &&
20
+ @matrix.__getobj__[row_index][column_index]
21
+ else
22
+ @matrix.__getobj__[row_index] || EnsuredAccess.new(@row_type.new)
23
+ end
24
+ end
25
+
26
+ def []=(row_index, column_index, entry)
27
+ @max_column_index = nil # bust the max_column_index cache
28
+
29
+ @matrix[row_index][column_index] = entry
30
+ ensure_rows_length_are_equal!
31
+
32
+ @matrix
33
+ end
34
+
35
+ def each
36
+ @matrix.each do |row|
37
+ yield row
38
+ end
39
+ end
40
+
41
+ def max_column_index
42
+ @max_column_index ||= (@matrix.compact.max_by(&:length) || []).length - 1
43
+ end
44
+
45
+ private
46
+
47
+ def ensure_rows_length_are_equal!
48
+ @matrix.each_with_index do |_column, index|
49
+ @matrix[index][max_column_index] ||= nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailwayOperation
4
+ module Generic
5
+ # Ensure that only elements of specified type(s) are accepted in the array
6
+ class TypedArray < Delegator
7
+ class UnacceptableMember < StandardError; end
8
+ DEFAULT_MESSAGE = ->(type) { "unacceptable element in array, all elements must be of type #{type}" }.freeze
9
+
10
+ def initialize(array = [], ensure_type_is:, error_message: nil)
11
+ raise(ArgumentError, 'must be initialized with an array') unless array.is_a?(Array)
12
+
13
+ @types = wrap(ensure_type_is)
14
+ @error_message = error_message || DEFAULT_MESSAGE.(ensure_type_is)
15
+ __setobj__(array)
16
+ end
17
+
18
+ def __setobj__(arr)
19
+ raise UnacceptableMember, @error_message unless array_acceptable?(arr)
20
+ @arr = arr
21
+ end
22
+
23
+ def __getobj__
24
+ @arr
25
+ end
26
+
27
+ def <<(element)
28
+ raise UnacceptableMember, @error_message unless element_acceptable?(element)
29
+ @arr << element
30
+ end
31
+
32
+ def array_acceptable?(arr)
33
+ arr&.all? { |a| element_acceptable?(a) }
34
+ end
35
+
36
+ def element_acceptable?(element)
37
+ class_acceptable?(element) || instance_acceptable?(element)
38
+ end
39
+
40
+ private
41
+
42
+ def class_acceptable?(element)
43
+ return false unless element.is_a?(Class)
44
+ @types.detect { |type| element <= type }
45
+ end
46
+
47
+ def instance_acceptable?(element)
48
+ @types.detect { |type| element.is_a?(type) }
49
+ end
50
+
51
+ # Taken from ActiveSupport Array.wrap https://apidock.com/rails/Array/wrap/class
52
+ def wrap(object)
53
+ if object.respond_to?(:to_ary)
54
+ object.to_ary || [object]
55
+ else
56
+ [object]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailwayOperation
4
+ class Info < DelegateClass(Hash)
5
+ def new(maybe_obj)
6
+ maybe_obj.is_a?(Info) ? maybe_obj : super
7
+ end
8
+
9
+ def initialize(operation:, **info)
10
+ super info.merge(operation: operation)
11
+ end
12
+
13
+ def operation
14
+ self[:operation]
15
+ end
16
+
17
+ def operation=(op)
18
+ self[:operation] = op
19
+ end
20
+
21
+ def execution
22
+ self[:execution] = Execution.new(self[:execution] || [])
23
+ end
24
+ end
25
+
26
+ # This is intended to extend the functionality of a normal
27
+ # hash to make it easier to inspect the log
28
+ class Execution < DelegateClass(Array)
29
+ def new(maybe_obj)
30
+ maybe_obj.is_a?(Execution) ? maybe_obj : super
31
+ end
32
+
33
+ def initialize(obj = [])
34
+ super
35
+ end
36
+
37
+ def <<(value)
38
+ super Step.new(value)
39
+ end
40
+
41
+ def []=(index, value)
42
+ super index, Step.new(value)
43
+ end
44
+
45
+ def first_step
46
+ first
47
+ end
48
+
49
+ def last_step
50
+ last
51
+ end
52
+
53
+ def add_error(error)
54
+ last.add_error(error)
55
+ end
56
+
57
+ def errored?
58
+ any?(&:errored?)
59
+ end
60
+
61
+ def success?
62
+ all?(&:success?)
63
+ end
64
+
65
+ def failed?
66
+ !success?
67
+ end
68
+
69
+ def completed?
70
+ all?(&:completed?)
71
+ end
72
+
73
+ def add_step(argument:, track_identifier:, step_index:)
74
+ self << {
75
+ argument: argument,
76
+ track_identifier: track_identifier,
77
+ step_index: step_index
78
+ }
79
+
80
+ last
81
+ end
82
+
83
+ def display
84
+ table = Terminal::Table.new
85
+ table.title = 'Execution'
86
+ table.headings = ['', 'Track', 'Success', 'Method', 'Errors']
87
+ table.rows = self.map do |s|
88
+ [
89
+ s[:step_index],
90
+ s[:track_identifier],
91
+ s.success?,
92
+ s[:noop] ? '--' : (s[:method].is_a?(Proc) ? 'Proc' : s[:method]),
93
+ s[:errors]
94
+ ]
95
+ end
96
+
97
+ table.to_s
98
+ end
99
+ end
100
+
101
+ class Step < DelegateClass(Hash)
102
+ def new(maybe_obj)
103
+ maybe_obj.is_a?(Step) ? maybe_obj : super
104
+ end
105
+
106
+ def initialize(obj = {})
107
+ super
108
+ end
109
+
110
+ def started_at
111
+ self[:started_at]
112
+ end
113
+
114
+ def ended_at
115
+ self[:ended_at]
116
+ end
117
+
118
+ def completed_at
119
+ ended_at
120
+ end
121
+
122
+ def started?
123
+ self[:started_at]
124
+ end
125
+
126
+ def completed?
127
+ started? && self[:ended_at]
128
+ end
129
+
130
+ def success?
131
+ errors.empty? && !self[:failed]
132
+ end
133
+
134
+ def errored?
135
+ !errors.empty?
136
+ end
137
+
138
+ def failed?
139
+ !!self[:failed]
140
+ end
141
+
142
+ def noop?
143
+ self[:noop]
144
+ end
145
+
146
+ def start!
147
+ self[:started_at] = timestamp
148
+ end
149
+
150
+ def end!
151
+ raise 'cannot complete step that has not yet started' unless started?
152
+ self[:ended_at] = timestamp
153
+ end
154
+
155
+ def fail!(error)
156
+ self[:failed_at] = timestamp
157
+ add_error(error)
158
+ end
159
+
160
+ def add_error(error)
161
+ errors << error if error
162
+ end
163
+
164
+ def noop!
165
+ self[:started_at] = self[:ended_at] = timestamp
166
+ self[:method] = nil
167
+ self[:noop] = true
168
+ end
169
+
170
+ def errors
171
+ self[:errors] ||= []
172
+ self[:errors]
173
+ end
174
+
175
+ def track_identifier
176
+ self[:track_identifier]
177
+ end
178
+
179
+ def step_index
180
+ self[:step_index]
181
+ end
182
+
183
+ def timestamp
184
+ Time.respond_to?(:current) ? Time.current : Time.now
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailwayOperation
4
+ # This is the value object that holds the information necessary to
5
+ # run an operation
6
+ class Operation
7
+ class NonExistentTrack < StandardError; end
8
+ extend Forwardable
9
+
10
+ attr_reader :name, :track_alias
11
+ attr_accessor :operation_surrounds,
12
+ :step_surrounds
13
+
14
+ def self.new(operation_or_name)
15
+ return operation_or_name if operation_or_name.is_a?(Operation)
16
+ super
17
+ end
18
+
19
+ def self.format_name(op_or_name)
20
+ case op_or_name
21
+ when Operation
22
+ op_or_name.name
23
+ when String, Symbol
24
+ op_or_name.to_s.gsub(/\s+/, '_').downcase.to_sym
25
+ else
26
+ raise 'invalid operation name'
27
+ end
28
+ end
29
+
30
+ def initialize(name)
31
+ @name = self.class.format_name(name)
32
+ @operation_surrounds = []
33
+ @step_surrounds = Generic::EnsuredAccess.new({}) { StepsArray.new }
34
+ @track_alias = [noop_track]
35
+ @tracks = Generic::FilledMatrix.new(row_type: StepsArray)
36
+ end
37
+
38
+ def [](track_identifier, step_index = nil)
39
+ tracks[
40
+ track_index(track_identifier),
41
+ step_index
42
+ ]
43
+ end
44
+
45
+ def []=(track_identifier, step_index, step)
46
+ tracks[
47
+ track_index(track_identifier),
48
+ step_index
49
+ ] = step
50
+ end
51
+
52
+ def add_step(track_identifier, method = nil, &block)
53
+ self[track_identifier, last_step_index + 1] = block || method
54
+ end
55
+
56
+ def stepper_function(fn = nil, &block)
57
+ @stepper_function ||= fn || block
58
+ end
59
+
60
+ def tracks(*names)
61
+ return @tracks if names.empty?
62
+ @track_alias = [noop_track, *names]
63
+ end
64
+
65
+ def strategy(tracks, stepper_fn)
66
+ tracks(*tracks)
67
+ stepper_function(stepper_fn)
68
+ end
69
+
70
+ def last_step_index
71
+ tracks.max_column_index
72
+ end
73
+
74
+ def successor_track(track_id)
75
+ next_index = track_index(track_id) + 1
76
+ return if tracks.count <= next_index
77
+
78
+ if track_id.is_a?(Numeric)
79
+ next_index
80
+ else
81
+ track_identifier(next_index)
82
+ end
83
+ end
84
+
85
+ def track_identifier(index_or_id)
86
+ return index_or_id unless index_or_id.is_a?(Integer)
87
+ validate_index(index_or_id)
88
+
89
+ @track_alias[index_or_id] || index_or_id
90
+ end
91
+
92
+ def track_index(track_identifier)
93
+ index = @track_alias.index(track_identifier) || track_identifier
94
+ validate_index(index)
95
+
96
+ index
97
+ end
98
+
99
+ def noop_track
100
+ :noop_track
101
+ end
102
+
103
+ def initial_track
104
+ track_identifier(1)
105
+ end
106
+
107
+ private
108
+
109
+ def validate_index(index)
110
+ unless index.is_a?(Integer) && index.positive?
111
+ raise "Invalid track `#{index}`, must be a positive integer"
112
+ end
113
+ end
114
+ end
115
+ end