railway_operation 0.1.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,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