teckel 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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