teckel 0.1.0 → 0.6.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -0
  3. data/LICENSE_LOGO +4 -0
  4. data/README.md +22 -14
  5. data/lib/teckel.rb +11 -2
  6. data/lib/teckel/chain.rb +47 -152
  7. data/lib/teckel/chain/config.rb +246 -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 +41 -52
  12. data/lib/teckel/contracts.rb +19 -0
  13. data/lib/teckel/operation.rb +108 -253
  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 +75 -0
  17. data/lib/teckel/result.rb +52 -53
  18. data/lib/teckel/version.rb +1 -1
  19. data/spec/chain/default_settings_spec.rb +39 -0
  20. data/spec/chain/inheritance_spec.rb +116 -0
  21. data/spec/chain/none_input_spec.rb +36 -0
  22. data/spec/chain/results_spec.rb +53 -0
  23. data/spec/chain_around_hook_spec.rb +100 -0
  24. data/spec/chain_spec.rb +180 -0
  25. data/spec/config_spec.rb +26 -0
  26. data/spec/doctest_helper.rb +7 -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/inheritance_spec.rb +94 -0
  30. data/spec/operation/result_spec.rb +34 -0
  31. data/spec/operation/results_spec.rb +117 -0
  32. data/spec/operation_spec.rb +483 -0
  33. data/spec/rb27/pattern_matching_spec.rb +193 -0
  34. data/spec/result_spec.rb +22 -0
  35. data/spec/spec_helper.rb +25 -0
  36. data/spec/support/dry_base.rb +8 -0
  37. data/spec/support/fake_db.rb +12 -0
  38. data/spec/support/fake_models.rb +20 -0
  39. data/spec/teckel_spec.rb +7 -0
  40. metadata +64 -46
  41. data/.github/workflows/ci.yml +0 -67
  42. data/.github/workflows/pages.yml +0 -50
  43. data/.gitignore +0 -13
  44. data/.rspec +0 -3
  45. data/.rubocop.yml +0 -12
  46. data/.ruby-version +0 -1
  47. data/DEVELOPMENT.md +0 -28
  48. data/Gemfile +0 -8
  49. data/Gemfile.lock +0 -71
  50. data/Rakefile +0 -30
  51. data/bin/console +0 -15
  52. data/bin/rake +0 -29
  53. data/bin/rspec +0 -29
  54. data/bin/rubocop +0 -18
  55. data/bin/setup +0 -8
  56. data/lib/teckel/operation/results.rb +0 -71
  57. data/teckel.gemspec +0 -33
@@ -0,0 +1,246 @@
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
+ #
107
+ # class Result < Teckel::Operation::Result
108
+ # def initialize(value, success, step, options = {}); end
109
+ # end
110
+ #
111
+ # # If you need more control over how to build a new +Settings+ instance
112
+ # result_constructor ->(value, success, step) { result.new(value, success, step, {foo: :bar}) }
113
+ # end
114
+ def result_constructor(sym_or_proc = nil)
115
+ constructor = build_constructor(result, sym_or_proc) unless sym_or_proc.nil?
116
+
117
+ @config.for(:result_constructor, constructor) {
118
+ build_constructor(result, Teckel::DEFAULT_CONSTRUCTOR)
119
+ } || raise(MissingConfigError, "Missing result_constructor config for #{self}")
120
+ end
121
+
122
+ # Declare default settings operation iin this chain should use when called without
123
+ # {Teckel::Chain::ClassMethods#with #with}.
124
+ #
125
+ # Explicit call-time settings will *not* get merged with declared default setting.
126
+ #
127
+ # @param settings [Hash{String,Symbol => Object}] Set settings for a step by it's name
128
+ #
129
+ # @example
130
+ # class MyOperation
131
+ # include Teckel::Operation
132
+ # result!
133
+ #
134
+ # settings Struct.new(:say, :other)
135
+ # settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) }
136
+ #
137
+ # input none
138
+ # output Hash
139
+ # error none
140
+ #
141
+ # def call(_)
142
+ # success!(settings.to_h)
143
+ # end
144
+ # end
145
+ #
146
+ # class Chain
147
+ # include Teckel::Chain
148
+ #
149
+ # default_settings!(a: { say: "Chain Default" })
150
+ #
151
+ # step :a, MyOperation
152
+ # end
153
+ #
154
+ # # Using the chains default settings
155
+ # result = Chain.call
156
+ # result.success #=> {say: "Chain Default", other: nil}
157
+ #
158
+ # # explicit settings passed via `with` will overwrite all defaults
159
+ # result = Chain.with(a: { other: "What" }).call
160
+ # result.success #=> {say: nil, other: "What"}
161
+ def default_settings!(settings) # :nodoc: The bang is for consistency with the Operation class
162
+ @config.for(:default_settings, settings)
163
+ end
164
+
165
+ # Getter for configured default settings
166
+ # @return [nil|#call] The callable constructor
167
+ def default_settings
168
+ @config.for(:default_settings)
169
+ end
170
+
171
+ REQUIRED_CONFIGS = %i[around runner result result_constructor].freeze
172
+
173
+ # @!visibility private
174
+ # @return [void]
175
+ def define!
176
+ raise MissingConfigError, "Cannot define Chain with no steps" if steps.empty?
177
+
178
+ REQUIRED_CONFIGS.each { |e| public_send(e) }
179
+ steps.each(&:finalize!)
180
+ nil
181
+ end
182
+
183
+ # Disallow any further changes to this Chain.
184
+ # @note This also calls +finalize!+ on all Operations defined as steps.
185
+ #
186
+ # @return [self] Frozen self
187
+ # @!visibility public
188
+ def finalize!
189
+ define!
190
+ steps.freeze
191
+ @config.freeze
192
+ self
193
+ end
194
+
195
+ # Produces a shallow copy of this chain.
196
+ # It's {around}, {runner} and {steps} will get +dup+'ed
197
+ #
198
+ # @return [self]
199
+ # @!visibility public
200
+ def dup
201
+ dup_config(super)
202
+ end
203
+
204
+ # Produces a clone of this chain.
205
+ # It's {around}, {runner} and {steps} will get +dup+'ed
206
+ #
207
+ # @return [self]
208
+ # @!visibility public
209
+ def clone
210
+ if frozen?
211
+ super
212
+ else
213
+ dup_config(super)
214
+ end
215
+ end
216
+
217
+ # @!visibility private
218
+ def inherited(subclass)
219
+ super dup_config(subclass)
220
+ end
221
+
222
+ # @!visibility private
223
+ def self.extended(base)
224
+ base.instance_variable_set(:@config, Teckel::Config.new)
225
+ end
226
+
227
+ private
228
+
229
+ def dup_config(other_class)
230
+ new_config = @config.dup
231
+ new_config.replace(:steps) { steps.dup }
232
+
233
+ other_class.instance_variable_set(:@config, new_config)
234
+ other_class
235
+ end
236
+
237
+ def build_constructor(on, sym_or_proc)
238
+ if sym_or_proc.is_a?(Symbol) && on.respond_to?(sym_or_proc)
239
+ on.public_method(sym_or_proc)
240
+ elsif sym_or_proc.respond_to?(:call)
241
+ sym_or_proc
242
+ end
243
+ end
244
+ end
245
+ end
246
+ 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,68 +2,57 @@
2
2
 
3
3
  module Teckel
4
4
  class Config
5
- class FrozenConfigError < Teckel::Error; end
6
-
7
- @default_constructor = :[]
8
- class << self
9
- def default_constructor(sym_or_proc = nil)
10
- return @default_constructor if sym_or_proc.nil?
11
-
12
- @default_constructor = sym_or_proc
13
- end
14
- end
15
-
5
+ # @!visibility private
16
6
  def initialize
17
- @input_class = nil
18
- @input_constructor = nil
19
-
20
- @output_class = nil
21
- @output_constructor = nil
22
-
23
- @error_class = nil
24
- @error_constructor = nil
25
- end
26
-
27
- def input(klass = nil)
28
- return @input_class if klass.nil?
29
- raise FrozenConfigError unless @input_class.nil?
30
-
31
- @input_class = klass
7
+ @config = {}
8
+ end
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
19
+ # @!visibility private
20
+ def for(key, value = nil, &block)
21
+ if value.nil?
22
+ get_or_set(key, &block)
23
+ elsif @config.key?(key)
24
+ raise FrozenConfigError, "Configuration #{key} is already set"
25
+ else
26
+ @config[key] = value
27
+ end
32
28
  end
33
29
 
34
- def input_constructor(sym_or_proc = nil)
35
- return (@input_constructor || self.class.default_constructor) if sym_or_proc.nil?
36
- raise FrozenConfigError unless @input_constructor.nil?
37
-
38
- @input_constructor = sym_or_proc
30
+ # @!visibility private
31
+ def replace(key)
32
+ @config[key] = yield if @config.key?(key)
39
33
  end
40
34
 
41
- def output(klass = nil)
42
- return @output_class if klass.nil?
43
- raise FrozenConfigError unless @output_class.nil?
44
-
45
- @output_class = klass
35
+ # @!visibility private
36
+ def freeze
37
+ @config.freeze
38
+ super
46
39
  end
47
40
 
48
- def output_constructor(sym_or_proc = nil)
49
- return (@output_constructor || self.class.default_constructor) if sym_or_proc.nil?
50
- raise FrozenConfigError unless @output_constructor.nil?
51
-
52
- @output_constructor = sym_or_proc
53
- end
54
-
55
- def error(klass = nil)
56
- return @error_class if klass.nil?
57
- raise FrozenConfigError unless @error_class.nil?
58
-
59
- @error_class = klass
41
+ # @!visibility private
42
+ def dup
43
+ super.tap do |copy|
44
+ copy.instance_variable_set(:@config, @config.dup)
45
+ end
60
46
  end
61
47
 
62
- def error_constructor(sym_or_proc = nil)
63
- return (@error_constructor || self.class.default_constructor) if sym_or_proc.nil?
64
- raise FrozenConfigError unless @error_constructor.nil?
48
+ private
65
49
 
66
- @error_constructor = sym_or_proc
50
+ def get_or_set(key, &block)
51
+ if block
52
+ @config[key] ||= @config.fetch(key, &block)
53
+ else
54
+ @config[key]
55
+ end
67
56
  end
68
57
  end
69
58
  end