teckel 0.2.0 → 0.7.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/LICENSE_LOGO +4 -0
  4. data/README.md +4 -4
  5. data/lib/teckel.rb +9 -4
  6. data/lib/teckel/chain.rb +31 -341
  7. data/lib/teckel/chain/config.rb +275 -0
  8. data/lib/teckel/chain/result.rb +38 -0
  9. data/lib/teckel/chain/runner.rb +62 -0
  10. data/lib/teckel/chain/step.rb +18 -0
  11. data/lib/teckel/config.rb +25 -28
  12. data/lib/teckel/contracts.rb +19 -0
  13. data/lib/teckel/operation.rb +84 -302
  14. data/lib/teckel/operation/config.rb +396 -0
  15. data/lib/teckel/operation/result.rb +92 -0
  16. data/lib/teckel/operation/runner.rb +74 -0
  17. data/lib/teckel/result.rb +52 -53
  18. data/lib/teckel/version.rb +1 -1
  19. data/spec/chain/around_hook_spec.rb +100 -0
  20. data/spec/chain/default_settings_spec.rb +39 -0
  21. data/spec/chain/inheritance_spec.rb +116 -0
  22. data/spec/chain/none_input_spec.rb +36 -0
  23. data/spec/chain/results_spec.rb +53 -0
  24. data/spec/chain_spec.rb +180 -0
  25. data/spec/config_spec.rb +26 -0
  26. data/spec/doctest_helper.rb +8 -0
  27. data/spec/operation/contract_trace_spec.rb +116 -0
  28. data/spec/operation/default_settings_spec.rb +94 -0
  29. data/spec/operation/fail_on_input_spec.rb +103 -0
  30. data/spec/operation/inheritance_spec.rb +94 -0
  31. data/spec/operation/result_spec.rb +55 -0
  32. data/spec/operation/results_spec.rb +117 -0
  33. data/spec/operation_spec.rb +483 -0
  34. data/spec/rb27/pattern_matching_spec.rb +193 -0
  35. data/spec/result_spec.rb +22 -0
  36. data/spec/spec_helper.rb +28 -0
  37. data/spec/support/dry_base.rb +8 -0
  38. data/spec/support/fake_db.rb +12 -0
  39. data/spec/support/fake_models.rb +20 -0
  40. data/spec/teckel_spec.rb +7 -0
  41. metadata +68 -28
  42. data/.codeclimate.yml +0 -3
  43. data/.github/workflows/ci.yml +0 -92
  44. data/.github/workflows/pages.yml +0 -50
  45. data/.gitignore +0 -15
  46. data/.rspec +0 -3
  47. data/.rubocop.yml +0 -12
  48. data/.ruby-version +0 -1
  49. data/DEVELOPMENT.md +0 -32
  50. data/Gemfile +0 -16
  51. data/Rakefile +0 -35
  52. data/bin/console +0 -15
  53. data/bin/rake +0 -29
  54. data/bin/rspec +0 -29
  55. data/bin/rubocop +0 -18
  56. data/bin/setup +0 -8
  57. data/lib/teckel/none.rb +0 -18
  58. data/lib/teckel/operation/results.rb +0 -72
  59. data/teckel.gemspec +0 -32
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ module Chain
5
+ module Config
6
+ # Declare a {Operation} as a named step
7
+ #
8
+ # @param name [String,Symbol] The name of the operation.
9
+ # This name is used in an error case to let you know which step failed.
10
+ # @param operation [Operation] The operation to call, which
11
+ # must return a {Teckel::Result} object.
12
+ def step(name, operation)
13
+ steps << Step.new(name, operation)
14
+ end
15
+
16
+ # Get the list of defined steps
17
+ #
18
+ # @return [<Step>]
19
+ def steps
20
+ @config.for(:steps) { [] }
21
+ end
22
+
23
+ # Set or get the optional around hook.
24
+ # A Hook might be given as a block or anything callable. The execution of
25
+ # the chain is yielded to this hook. The first argument being the callable
26
+ # chain ({Runner}) and the second argument the +input+ data. The hook also
27
+ # needs to return the result.
28
+ #
29
+ # @param callable [Proc,{#call}] The hook to pass chain execution control to. (nil)
30
+ #
31
+ # @return [Proc,{#call}] The configured hook
32
+ #
33
+ # @example Around hook with block
34
+ # OUTPUTS = []
35
+ #
36
+ # class Echo
37
+ # include ::Teckel::Operation
38
+ # result!
39
+ #
40
+ # input Hash
41
+ # output input
42
+ #
43
+ # def call(hsh)
44
+ # success!(hsh)
45
+ # end
46
+ # end
47
+ #
48
+ # class MyChain
49
+ # include Teckel::Chain
50
+ #
51
+ # around do |chain, input|
52
+ # OUTPUTS << "before start"
53
+ # result = chain.call(input)
54
+ # OUTPUTS << "after start"
55
+ # result
56
+ # end
57
+ #
58
+ # step :noop, Echo
59
+ # end
60
+ #
61
+ # result = MyChain.call(some: 'test')
62
+ # OUTPUTS #=> ["before start", "after start"]
63
+ # result.success #=> { some: "test" }
64
+ def around(callable = nil, &block)
65
+ @config.for(:around, callable || block)
66
+ end
67
+
68
+ # @!attribute [r] runner()
69
+ # @return [Class] The Runner class
70
+ # @!visibility protected
71
+
72
+ # Overwrite the default runner
73
+ # @param klass [Class] A class like the {Runner}
74
+ # @!visibility protected
75
+ def runner(klass = nil)
76
+ @config.for(:runner, klass) { Runner }
77
+ end
78
+
79
+ # @overload result()
80
+ # Get the configured result object class wrapping {.error} or {.output}.
81
+ # @return [Class] The +result+ class, or {Teckel::Chain::Result} as default
82
+ #
83
+ # @overload result(klass)
84
+ # Set the result object class wrapping {.error} or {.output}.
85
+ # @param klass [Class] The +result+ class
86
+ # @return [Class] The +result+ class configured
87
+ def result(klass = nil)
88
+ @config.for(:result, klass) { const_defined?(:Result, false) ? self::Result : Teckel::Chain::Result }
89
+ end
90
+
91
+ # @overload result_constructor()
92
+ # The callable constructor to build an instance of the +result+ class.
93
+ # Defaults to {Teckel::DEFAULT_CONSTRUCTOR}
94
+ # @return [Proc] A callable that will return an instance of +result+ class.
95
+ #
96
+ # @overload result_constructor(sym_or_proc)
97
+ # Define how to build the +result+.
98
+ # @param sym_or_proc [Symbol, #call]
99
+ # - Either a +Symbol+ representing the _public_ method to call on the +result+ class.
100
+ # - Or anything that response to +#call+ (like a +Proc+).
101
+ # @return [#call] The callable constructor
102
+ #
103
+ # @example
104
+ # class MyOperation
105
+ # include Teckel::Operation
106
+ # result!
107
+ #
108
+ # settings Struct.new(:say, :other)
109
+ # settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
110
+ #
111
+ # input none
112
+ # output Hash
113
+ # error none
114
+ #
115
+ # def call(_)
116
+ # success!(settings.to_h)
117
+ # end
118
+ # end
119
+ #
120
+ # class Chain
121
+ # include Teckel::Chain
122
+ #
123
+ # class Result < Teckel::Operation::Result
124
+ # def initialize(value, success, step, opts = {})
125
+ # super(value, success)
126
+ # @step = step
127
+ # @opts = opts
128
+ # end
129
+ #
130
+ # class << self
131
+ # alias :[] :new # Alias the default constructor to :new
132
+ # end
133
+ #
134
+ # attr_reader :opts, :step
135
+ # end
136
+ #
137
+ # result_constructor ->(value, success, step) {
138
+ # result.new(value, success, step, time: Time.now.to_i)
139
+ # }
140
+ #
141
+ # step :a, MyOperation
142
+ # end
143
+ def result_constructor(sym_or_proc = nil)
144
+ constructor = build_constructor(result, sym_or_proc) unless sym_or_proc.nil?
145
+
146
+ @config.for(:result_constructor, constructor) {
147
+ build_constructor(result, Teckel::DEFAULT_CONSTRUCTOR)
148
+ } || raise(MissingConfigError, "Missing result_constructor config for #{self}")
149
+ end
150
+
151
+ # Declare default settings operation iin this chain should use when called without
152
+ # {Teckel::Chain::ClassMethods#with #with}.
153
+ #
154
+ # Explicit call-time settings will *not* get merged with declared default setting.
155
+ #
156
+ # @param settings [Hash{String,Symbol => Object}] Set settings for a step by it's name
157
+ #
158
+ # @example
159
+ # class MyOperation
160
+ # include Teckel::Operation
161
+ # result!
162
+ #
163
+ # settings Struct.new(:say, :other)
164
+ # settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
165
+ #
166
+ # input none
167
+ # output Hash
168
+ # error none
169
+ #
170
+ # def call(_)
171
+ # success!(settings.to_h)
172
+ # end
173
+ # end
174
+ #
175
+ # class Chain
176
+ # include Teckel::Chain
177
+ #
178
+ # default_settings!(a: { say: "Chain Default" })
179
+ #
180
+ # step :a, MyOperation
181
+ # end
182
+ #
183
+ # # Using the chains default settings
184
+ # result = Chain.call
185
+ # result.success #=> {say: "Chain Default", other: nil}
186
+ #
187
+ # # explicit settings passed via `with` will overwrite all defaults
188
+ # result = Chain.with(a: { other: "What" }).call
189
+ # result.success #=> {say: nil, other: "What"}
190
+ def default_settings!(settings) # :nodoc: The bang is for consistency with the Operation class
191
+ @config.for(:default_settings, settings)
192
+ end
193
+
194
+ # Getter for configured default settings
195
+ # @return [nil|#call] The callable constructor
196
+ def default_settings
197
+ @config.for(:default_settings)
198
+ end
199
+
200
+ REQUIRED_CONFIGS = %i[around runner result result_constructor].freeze
201
+
202
+ # @!visibility private
203
+ # @return [void]
204
+ def define!
205
+ raise MissingConfigError, "Cannot define Chain with no steps" if steps.empty?
206
+
207
+ REQUIRED_CONFIGS.each { |e| public_send(e) }
208
+ steps.each(&:finalize!)
209
+ nil
210
+ end
211
+
212
+ # Disallow any further changes to this Chain.
213
+ # @note This also calls +finalize!+ on all Operations defined as steps.
214
+ #
215
+ # @return [self] Frozen self
216
+ # @!visibility public
217
+ def finalize!
218
+ define!
219
+ steps.freeze
220
+ @config.freeze
221
+ self
222
+ end
223
+
224
+ # Produces a shallow copy of this chain.
225
+ # It's {around}, {runner} and {steps} will get +dup+'ed
226
+ #
227
+ # @return [self]
228
+ # @!visibility public
229
+ def dup
230
+ dup_config(super)
231
+ end
232
+
233
+ # Produces a clone of this chain.
234
+ # It's {around}, {runner} and {steps} will get +dup+'ed
235
+ #
236
+ # @return [self]
237
+ # @!visibility public
238
+ def clone
239
+ if frozen?
240
+ super
241
+ else
242
+ dup_config(super)
243
+ end
244
+ end
245
+
246
+ # @!visibility private
247
+ def inherited(subclass)
248
+ super dup_config(subclass)
249
+ end
250
+
251
+ # @!visibility private
252
+ def self.extended(base)
253
+ base.instance_variable_set(:@config, Teckel::Config.new)
254
+ end
255
+
256
+ private
257
+
258
+ def dup_config(other_class)
259
+ new_config = @config.dup
260
+ new_config.replace(:steps) { steps.dup }
261
+
262
+ other_class.instance_variable_set(:@config, new_config)
263
+ other_class
264
+ end
265
+
266
+ def build_constructor(on, sym_or_proc)
267
+ if sym_or_proc.is_a?(Symbol) && on.respond_to?(sym_or_proc)
268
+ on.public_method(sym_or_proc)
269
+ elsif sym_or_proc.respond_to?(:call)
270
+ sym_or_proc
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Teckel
6
+ module Chain
7
+ class Result < Teckel::Operation::Result
8
+ extend Forwardable
9
+
10
+ # @param value [Object] The result value
11
+ # @param success [Boolean] whether this is a successful result
12
+ # @param step [Teckel::Chain::Step]
13
+ def initialize(value, success, step)
14
+ super(value, success)
15
+ @step = step
16
+ end
17
+
18
+ class << self
19
+ alias :[] :new
20
+ end
21
+
22
+ # @!method step
23
+ # Delegates to +step.name+
24
+ # @return [String,Symbol] The step name of the failed operation.
25
+ def_delegator :@step, :name, :step
26
+
27
+ def deconstruct
28
+ [successful?, @step.name, value]
29
+ end
30
+
31
+ def deconstruct_keys(keys)
32
+ e = super
33
+ e[:step] = @step.name if keys.include?(:step)
34
+ e
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ module Chain
5
+ # The default implementation for executing a {Chain}
6
+ #
7
+ # @!visibility protected
8
+ class Runner
9
+ # @!visibility private
10
+ UNDEFINED = Object.new
11
+
12
+ # @!visibility private
13
+ StepResult = Struct.new(:value, :success, :step)
14
+
15
+ def initialize(chain, settings = UNDEFINED)
16
+ @chain, @settings = chain, settings
17
+ end
18
+ attr_reader :chain, :settings
19
+
20
+ # Run steps
21
+ #
22
+ # @param input Any form of input the first steps +input+ class can handle
23
+ #
24
+ # @return [Teckel::Chain::Result] The result object wrapping
25
+ # either the success or failure value.
26
+ def call(input = nil)
27
+ step_result = run(input)
28
+ chain.result_constructor.call(*step_result)
29
+ end
30
+
31
+ def steps
32
+ settings == UNDEFINED ? chain.steps : steps_with_settings
33
+ end
34
+
35
+ private
36
+
37
+ def run(input)
38
+ steps.each_with_object(StepResult.new(input)) do |step, step_result|
39
+ result = step.operation.call(step_result.value)
40
+
41
+ step_result.step = step
42
+ step_result.value = result.value
43
+ step_result.success = result.successful?
44
+
45
+ break step_result if result.failure?
46
+ end
47
+ end
48
+
49
+ def step_with_settings(step)
50
+ settings.key?(step.name) ? step.with(settings[step.name]) : step
51
+ end
52
+
53
+ def steps_with_settings
54
+ Enumerator.new do |yielder|
55
+ chain.steps.each do |step|
56
+ yielder << step_with_settings(step)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ module Chain
5
+ # Internal wrapper of a step definition
6
+ Step = Struct.new(:name, :operation) do
7
+ def finalize!
8
+ name.freeze
9
+ operation.finalize!
10
+ freeze
11
+ end
12
+
13
+ def with(settings)
14
+ self.class.new(name, operation.with(settings))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -2,42 +2,24 @@
2
2
 
3
3
  module Teckel
4
4
  class Config
5
- @default_constructor = :[]
6
- class << self
7
- # @!attribute [r] default_constructor()
8
- # The default constructor method for +input+, +output+ and +error+ class (default: +:[]+)
9
- # @return [Class] The Output class
10
-
11
- # @!method default_constructor(sym_or_proc)
12
- # Set the default constructor method for +input+, +output+ and +error+ class
13
- #
14
- # defaults to +:[]+
15
- #
16
- # @param sym_or_proc [Symbol,#call] The method name on the +input+,
17
- # +output+ and +error+ class or a callable which accepts the
18
- # +input+, +output+ or +error+
19
- #
20
- # @return [Symbol,#call]
21
- def default_constructor(sym_or_proc = nil)
22
- return @default_constructor if sym_or_proc.nil?
23
-
24
- @default_constructor = sym_or_proc
25
- end
26
- end
27
-
28
5
  # @!visibility private
29
6
  def initialize
30
7
  @config = {}
31
8
  end
32
9
 
10
+ # Allow getting or setting a value, with some weird rules:
11
+ # - The +value+ might not be +nil+
12
+ # - Setting via +value+ is allowed only once. Successive calls will raise a {FrozenConfigError}
13
+ # - Setting via +block+ works almost like {Hash#fetch}:
14
+ # - returns the existing value if key is present
15
+ # - sets (and returns) the blocks return value otherwise
16
+ # - calling without +value+ and +block+ works like {Hash#[]}
17
+ #
18
+ # @raise [FrozenConfigError] When overwriting a key
33
19
  # @!visibility private
34
20
  def for(key, value = nil, &block)
35
21
  if value.nil?
36
- if block
37
- @config[key] ||= @config.fetch(key, &block)
38
- else
39
- @config[key]
40
- end
22
+ get_or_set(key, &block)
41
23
  elsif @config.key?(key)
42
24
  raise FrozenConfigError, "Configuration #{key} is already set"
43
25
  else
@@ -45,6 +27,11 @@ module Teckel
45
27
  end
46
28
  end
47
29
 
30
+ # @!visibility private
31
+ def replace(key)
32
+ @config[key] = yield if @config.key?(key)
33
+ end
34
+
48
35
  # @!visibility private
49
36
  def freeze
50
37
  @config.freeze
@@ -57,5 +44,15 @@ module Teckel
57
44
  copy.instance_variable_set(:@config, @config.dup)
58
45
  end
59
46
  end
47
+
48
+ private
49
+
50
+ def get_or_set(key, &block)
51
+ if block
52
+ @config[key] ||= @config.fetch(key, &block)
53
+ else
54
+ @config[key]
55
+ end
56
+ end
60
57
  end
61
58
  end