teckel 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE_LOGO +4 -0
  4. data/README.md +4 -4
  5. data/lib/teckel.rb +9 -3
  6. data/lib/teckel/chain.rb +99 -271
  7. data/lib/teckel/chain/result.rb +38 -0
  8. data/lib/teckel/chain/runner.rb +51 -0
  9. data/lib/teckel/chain/step.rb +18 -0
  10. data/lib/teckel/config.rb +1 -23
  11. data/lib/teckel/contracts.rb +19 -0
  12. data/lib/teckel/operation.rb +309 -215
  13. data/lib/teckel/operation/result.rb +92 -0
  14. data/lib/teckel/operation/runner.rb +70 -0
  15. data/lib/teckel/result.rb +52 -53
  16. data/lib/teckel/version.rb +1 -1
  17. data/spec/chain/inheritance_spec.rb +116 -0
  18. data/spec/chain/results_spec.rb +53 -0
  19. data/spec/chain_around_hook_spec.rb +100 -0
  20. data/spec/chain_spec.rb +180 -0
  21. data/spec/config_spec.rb +26 -0
  22. data/spec/doctest_helper.rb +7 -0
  23. data/spec/operation/inheritance_spec.rb +94 -0
  24. data/spec/operation/result_spec.rb +34 -0
  25. data/spec/operation/results_spec.rb +117 -0
  26. data/spec/operation_spec.rb +485 -0
  27. data/spec/rb27/pattern_matching_spec.rb +193 -0
  28. data/spec/result_spec.rb +20 -0
  29. data/spec/spec_helper.rb +25 -0
  30. data/spec/support/dry_base.rb +8 -0
  31. data/spec/support/fake_db.rb +12 -0
  32. data/spec/support/fake_models.rb +20 -0
  33. data/spec/teckel_spec.rb +7 -0
  34. metadata +52 -25
  35. data/.codeclimate.yml +0 -3
  36. data/.github/workflows/ci.yml +0 -92
  37. data/.github/workflows/pages.yml +0 -50
  38. data/.gitignore +0 -15
  39. data/.rspec +0 -3
  40. data/.rubocop.yml +0 -12
  41. data/.ruby-version +0 -1
  42. data/DEVELOPMENT.md +0 -32
  43. data/Gemfile +0 -16
  44. data/Rakefile +0 -35
  45. data/bin/console +0 -15
  46. data/bin/rake +0 -29
  47. data/bin/rspec +0 -29
  48. data/bin/rubocop +0 -18
  49. data/bin/setup +0 -8
  50. data/lib/teckel/none.rb +0 -18
  51. data/lib/teckel/operation/results.rb +0 -72
  52. data/teckel.gemspec +0 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 520b717d1e115614b1c93cfac56deac0e567bfc5d39e2ca6e93094f3c958fd9b
4
- data.tar.gz: d5f8f5a5dd2a2dabebe1add49ab139e9c5d14fd5c1fd3b1ef92f78c99c604401
3
+ metadata.gz: 18179b7fd315a11bc51f0ac4cfbdba4c0e74ad82a6a7dc186289433906ec2783
4
+ data.tar.gz: 02eb5f5c59ac9983a92b457f9253a38f9188e3f2b17d4f30029aa545b3151181
5
5
  SHA512:
6
- metadata.gz: cb4021c0102a0d18e45df23a40669984462b5d469608f16cefcff68fefa38a088ae4851f51052ba5b3c3f5305dc6d0abfbf2a50a81ca240748cf226ea5b66366
7
- data.tar.gz: 4298d3bff376cace7dd78a863fc60da2f7e4600f8032da19a3c7ecaee6168146fe41658af16e1811ad7de157cf15a37aa0bad42cdc19602de8eac1b2a2f49a83
6
+ metadata.gz: cbbbebfecccf6806da642dbc1dce843946c7dcaa27abb7c38467b0512ff5cb32d13b848fae0eaa0f8c4641f26e5b1f03f58f82fb70b3aa8aa1a891356698ff16
7
+ data.tar.gz: b312534680f31d7cff2a13326f3dc3cc01566110d3001b285d233c97548edbfb2fe008d59821ef0723b63687f12e95cca3f06f2f7c1a3f8d0e15d90caca4733d
@@ -1,5 +1,58 @@
1
1
  # Changes
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Moving verbose examples from API docs into github pages
6
+ - `#finalize!` no longer freezes the entire Operation or Chain class, only it's settings. [GH-13]
7
+ - Add simple support for using Base classes. [GH-10]
8
+ Removes global configuration `Teckel::Config.default_constructor`
9
+ ```ruby
10
+ class ApplicationOperation
11
+ include Teckel::Operation
12
+ # you won't be able to overwrite any configuration in child classes,
13
+ # so take care which you want to declare
14
+ result!
15
+ settings Struct.new(:logger)
16
+ input_constructor :new
17
+ error Struct.new(:status, :messages)
18
+
19
+ def log(message)
20
+ return unless settings&.logger
21
+ logger << message
22
+ end
23
+ # you cannot call `finalize!` on partially declared Operations
24
+ end
25
+ ```
26
+ - Add support for setting your own Result objects. [GH-9]
27
+ - They should include and implement `Teckel::Result` which is needed by `Chain`.
28
+ - `Chain::StepFailure` got replaced with `Chain::Result`.
29
+ - the `Teckel::Operation::Results` module was removed. To let Operation use the default Result object, use the new helper `result!` instead.
30
+ - Add "settings"/dependency injection to Operation and Chains. [GH-7]
31
+ ```ruby
32
+ MyOperation.with(logger: STDOUT).call(params)
33
+
34
+ MyChain.with(some_step: { logger: STDOUT }).call(params)
35
+ ```
36
+ - [GH-5] Add support for ruby 2.7 pattern matching on Operation and Chain results. Both, array and hash notations are supported:
37
+ ```ruby
38
+ case MyOperation.call(params)
39
+ in [false, value]
40
+ # handle failure
41
+ in [true, value]
42
+ # handle success
43
+ end
44
+
45
+ case MyChain.call(params)
46
+ in { success: false, step: :foo, value: value }
47
+ # handle foo failure
48
+ in [success: false, step: :bar, value: value }
49
+ # handle bar failure
50
+ in { success: true, value: value }
51
+ # handle success
52
+ end
53
+ ```
54
+ - Fix setting a config twice to raise an error
55
+
3
56
  ## 0.3.0
4
57
 
5
58
  - `finalize!`'ing a Chain will also finalize all it's Operations
@@ -0,0 +1,4 @@
1
+ The Teckel Logo artwork is NOT part of the Work as described in the LICENSE.
2
+ Any Derivative Works shall not use the Teckel Logo.
3
+
4
+ Copyright 2020 Jana Vogel <jana@dotless.de>
data/README.md CHANGED
@@ -34,11 +34,11 @@ Working with [Interactor](https://github.com/collectiveidea/interactor), [Trailb
34
34
 
35
35
  ## Usage
36
36
 
37
- For a full overview please see the Api Docs:
37
+ For a full overview please see the Docs:
38
38
 
39
- * [Operations](https://fnordfish.github.io/teckel/doc/Teckel/Operation.html)
40
- * [Operations with Result objects](https://fnordfish.github.io/teckel/doc/Teckel/Operation/Results.html)
41
- * [Chains](https://fnordfish.github.io/teckel/doc/Teckel/Chain.html)
39
+ * [Operations](https://fnordfish.github.io/teckel/operations/basics/)
40
+ * [Result Objects](https://fnordfish.github.io/teckel/operations/result_objects/)
41
+ * [Chains](https://fnordfish.github.io/teckel/chains/basics/)
42
42
 
43
43
 
44
44
  ```ruby
@@ -3,14 +3,20 @@
3
3
  require "teckel/version"
4
4
 
5
5
  module Teckel
6
+ # Base error class for this lib
6
7
  class Error < StandardError; end
8
+
9
+ # configuring the same value twice will raise this
7
10
  class FrozenConfigError < Teckel::Error; end
11
+
12
+ # missing important configurations (like contracts) will raise this
8
13
  class MissingConfigError < Teckel::Error; end
14
+
15
+ DEFAULT_CONSTRUCTOR = :[]
9
16
  end
10
17
 
11
18
  require_relative "teckel/config"
12
- require_relative "teckel/none"
13
- require_relative "teckel/operation"
19
+ require_relative "teckel/contracts"
14
20
  require_relative "teckel/result"
15
- require_relative "teckel/operation/results"
21
+ require_relative "teckel/operation"
16
22
  require_relative "teckel/chain"
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'forwardable'
3
+ require_relative 'chain/step'
4
+ require_relative 'chain/result'
5
+ require_relative 'chain/runner'
4
6
 
5
7
  module Teckel
6
8
  # Railway style execution of multiple Operations.
@@ -8,253 +10,11 @@ module Teckel
8
10
  # - Runs multiple Operations (steps) in order.
9
11
  # - The output of an earlier step is passed as input to the next step.
10
12
  # - Any failure will stop the execution chain (none of the later steps is called).
11
- # - All Operations (steps) must behave like
12
- # {Teckel::Operation::Results Teckel::Operation::Results} and return a result
13
- # object like {Teckel::Result}
14
- # - A failure response is wrapped into a {Teckel::Chain::StepFailure} giving
15
- # additional information about which step failed
13
+ # - All Operations (steps) must return a {Teckel::Result}
14
+ # - The result is wrapped into a {Teckel::Chain::Result}
16
15
  #
17
- # @see Teckel::Operation::Results
18
- #
19
- # @example Defining a simple Chain with three steps
20
- # class CreateUser
21
- # include ::Teckel::Operation::Results
22
- #
23
- # input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
24
- # output Types.Instance(User)
25
- # error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
26
- #
27
- # def call(input)
28
- # user = User.new(name: input[:name], age: input[:age])
29
- # if user.save
30
- # success!(user)
31
- # else
32
- # fail!(message: "Could not save User", errors: user.errors)
33
- # end
34
- # end
35
- # end
36
- #
37
- # class LogUser
38
- # include ::Teckel::Operation::Results
39
- #
40
- # input Types.Instance(User)
41
- # output input
42
- #
43
- # def call(usr)
44
- # Logger.new(File::NULL).info("User #{usr.name} created")
45
- # usr # we need to return the correct output type
46
- # end
47
- # end
48
- #
49
- # class AddFriend
50
- # class << self
51
- # # Don't actually do this! It's not safe and for generating the failure sample only.
52
- # attr_accessor :fail_befriend
53
- # end
54
- #
55
- # include ::Teckel::Operation::Results
56
- #
57
- # input Types.Instance(User)
58
- # output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
59
- # error Types::Hash.schema(message: Types::String)
60
- #
61
- # def call(user)
62
- # if self.class.fail_befriend
63
- # fail!(message: "Did not find a friend.")
64
- # else
65
- # { user: user, friend: User.new(name: "A friend", age: 42) }
66
- # end
67
- # end
68
- # end
69
- #
70
- # class MyChain
71
- # include Teckel::Chain
72
- #
73
- # step :create, CreateUser
74
- # step :log, LogUser
75
- # step :befriend, AddFriend
76
- # end
77
- #
78
- # result = MyChain.call(name: "Bob", age: 23)
79
- # result.is_a?(Teckel::Result) #=> true
80
- # result.success[:user].is_a?(User) #=> true
81
- # result.success[:friend].is_a?(User) #=> true
82
- #
83
- # AddFriend.fail_befriend = true
84
- # failure_result = MyChain.call(name: "Bob", age: 23)
85
- # failure_result.is_a?(Teckel::Chain::StepFailure) #=> true
86
- #
87
- # # additional step information
88
- # failure_result.step #=> :befriend
89
- # failure_result.operation #=> AddFriend
90
- #
91
- # # otherwise behaves just like a normal +Result+
92
- # failure_result.failure? #=> true
93
- # failure_result.failure #=> {message: "Did not find a friend."}
94
- #
95
- # @example DB transaction around hook
96
- # class CreateUser
97
- # include ::Teckel::Operation::Results
98
- #
99
- # input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
100
- # output Types.Instance(User)
101
- # error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
102
- #
103
- # def call(input)
104
- # user = User.new(name: input[:name], age: input[:age])
105
- # if user.save
106
- # success!(user)
107
- # else
108
- # fail!(message: "Could not safe User", errors: user.errors)
109
- # end
110
- # end
111
- # end
112
- #
113
- # class AddFriend
114
- # class << self
115
- # # Don't actually do this! It's not safe and for generating the failure sample only.
116
- # attr_accessor :fail_befriend
117
- # end
118
- #
119
- # include ::Teckel::Operation::Results
120
- #
121
- # input Types.Instance(User)
122
- # output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
123
- # error Types::Hash.schema(message: Types::String)
124
- #
125
- # def call(user)
126
- # if self.class.fail_befriend
127
- # fail!(message: "Did not find a friend.")
128
- # else
129
- # { user: user, friend: User.new(name: "A friend", age: 42) }
130
- # end
131
- # end
132
- # end
133
- #
134
- # LOG = []
135
- #
136
- # class MyChain
137
- # include Teckel::Chain
138
- #
139
- # around ->(chain, input) {
140
- # result = nil
141
- # begin
142
- # LOG << :before
143
- #
144
- # FakeDB.transaction do
145
- # result = chain.call(input)
146
- # raise FakeDB::Rollback if result.failure?
147
- # end
148
- #
149
- # LOG << :after
150
- # result
151
- # rescue FakeDB::Rollback
152
- # LOG << :rollback
153
- # result
154
- # end
155
- # }
156
- #
157
- # step :create, CreateUser
158
- # step :befriend, AddFriend
159
- # end
160
- #
161
- # AddFriend.fail_befriend = true
162
- # failure_result = MyChain.call(name: "Bob", age: 23)
163
- # failure_result.is_a?(Teckel::Chain::StepFailure) #=> true
164
- #
165
- # # triggered DB rollback
166
- # LOG #=> [:before, :rollback]
167
- #
168
- # # additional step information
169
- # failure_result.step #=> :befriend
170
- # failure_result.operation #=> AddFriend
171
- #
172
- # # otherwise behaves just like a normal +Result+
173
- # failure_result.failure? #=> true
174
- # failure_result.failure #=> {message: "Did not find a friend."}
16
+ # @see Teckel::Operation#result!
175
17
  module Chain
176
- # Internal wrapper of a step definition
177
- Step = Struct.new(:name, :operation) do
178
- def finalize!
179
- name.freeze
180
- operation.finalize!
181
- freeze
182
- end
183
- end
184
-
185
- # Like {Teckel::Result Teckel::Result} but for failing Chains
186
- #
187
- # When a Chain fails, it stores the failed +Operation+ and it's name.
188
- class StepFailure
189
- extend Forwardable
190
-
191
- def initialize(step, result)
192
- @step, @result = step, result
193
- end
194
-
195
- # @!method step
196
- # Delegates to +step.name+
197
- # @return [String,Symbol] The name of the failed operation.
198
- def_delegator :@step, :name, :step
199
-
200
- # @!method operation
201
- # Delegates to +step.operation+
202
- # @return [Teckel::Operation] The failed Operation class.
203
- def_delegator :@step, :operation
204
-
205
- # @!attribute result [R]
206
- # @return [Teckel::Result] the failure Result
207
- attr_reader :result
208
-
209
- # @!method value
210
- # Delegates to +result.value+
211
- # @see Teckel::Result#value
212
- # @!method successful?
213
- # Delegates to +result.successful?+
214
- # @see Teckel::Result#successful?
215
- # @!method success
216
- # Delegates to +result.success+
217
- # @see Teckel::Result#success
218
- # @!method failure?
219
- # Delegates to +result.failure?+
220
- # @see Teckel::Result#failure?
221
- # @!method failure
222
- # Delegates to +result.failure+
223
- # @see Teckel::Result#failure
224
- def_delegators :@result, :value, :successful?, :success, :failure?, :failure
225
- end
226
-
227
- # The default implementation for executing a {Chain}
228
- #
229
- # @!visibility protected
230
- class Runner
231
- def initialize(steps)
232
- @steps = steps
233
- end
234
- attr_reader :steps
235
-
236
- # Run steps
237
- #
238
- # @param input Any form of input the first steps +input+ class can handle
239
- #
240
- # @return [Teckel::Result,Teckel::Chain::StepFailure] The result object wrapping
241
- # either the success or failure value. Note that the {StepFailure} behaves
242
- # just like a {Teckel::Result} with added information about which step failed.
243
- def call(input)
244
- last_result = input
245
- failed = nil
246
- steps.each do |step|
247
- last_result = step.operation.call(last_result)
248
- if last_result.failure?
249
- failed = StepFailure.new(step, last_result)
250
- break
251
- end
252
- end
253
-
254
- failed || last_result
255
- end
256
- end
257
-
258
18
  module ClassMethods
259
19
  # The expected input for this chain
260
20
  # @return [Class] The {Teckel::Operation.input} of the first step
@@ -281,8 +41,8 @@ module Teckel
281
41
  #
282
42
  # @param name [String,Symbol] The name of the operation.
283
43
  # This name is used in an error case to let you know which step failed.
284
- # @param operation [Operation::Results] The operation to call.
285
- # Must return a {Teckel::Result} object.
44
+ # @param operation [Operation] The operation to call, which
45
+ # must return a {Teckel::Result} object.
286
46
  def step(name, operation)
287
47
  steps << Step.new(name, operation)
288
48
  end
@@ -308,7 +68,8 @@ module Teckel
308
68
  # OUTPUTS = []
309
69
  #
310
70
  # class Echo
311
- # include ::Teckel::Operation::Results
71
+ # include ::Teckel::Operation
72
+ # result!
312
73
  #
313
74
  # input Hash
314
75
  # output input
@@ -349,15 +110,57 @@ module Teckel
349
110
  @config.for(:runner, klass) { Runner }
350
111
  end
351
112
 
113
+ # @overload result()
114
+ # Get the configured result object class wrapping {.error} or {.output}.
115
+ # @return [Class] The +result+ class, or {Teckel::Chain::Result} as default
116
+ #
117
+ # @overload result(klass)
118
+ # Set the result object class wrapping {.error} or {.output}.
119
+ # @param klass [Class] The +result+ class
120
+ # @return [Class] The +result+ class configured
121
+ def result(klass = nil)
122
+ @config.for(:result, klass) { const_defined?(:Result, false) ? self::Result : Teckel::Chain::Result }
123
+ end
124
+
125
+ # @overload result_constructor()
126
+ # The callable constructor to build an instance of the +result+ class.
127
+ # Defaults to {Teckel::DEFAULT_CONSTRUCTOR}
128
+ # @return [Proc] A callable that will return an instance of +result+ class.
129
+ #
130
+ # @overload result_constructor(sym_or_proc)
131
+ # Define how to build the +result+.
132
+ # @param sym_or_proc [Symbol, #call]
133
+ # - Either a +Symbol+ representing the _public_ method to call on the +result+ class.
134
+ # - Or anything that response to +#call+ (like a +Proc+).
135
+ # @return [#call] The callable constructor
136
+ #
137
+ # @example
138
+ # class MyOperation
139
+ # include Teckel::Operation
140
+ #
141
+ # class Result < Teckel::Operation::Result
142
+ # def initialize(value, success, step, options = {}); end
143
+ # end
144
+ #
145
+ # # If you need more control over how to build a new +Settings+ instance
146
+ # result_constructor ->(value, success, step) { result.new(value, success, step, {foo: :bar}) }
147
+ # end
148
+ def result_constructor(sym_or_proc = nil)
149
+ constructor = build_counstructor(result, sym_or_proc) unless sym_or_proc.nil?
150
+
151
+ @config.for(:result_constructor, constructor) {
152
+ build_counstructor(result, Teckel::DEFAULT_CONSTRUCTOR)
153
+ } || raise(MissingConfigError, "Missing result_constructor config for #{self}")
154
+ end
155
+
352
156
  # The primary interface to call the chain with the given input.
353
157
  #
354
158
  # @param input Any form of input the first steps +input+ class can handle
355
159
  #
356
- # @return [Teckel::Result,Teckel::Chain::StepFailure] The result object wrapping
357
- # either the success or failure value. Note that the {StepFailure} behaves
358
- # just like a {Teckel::Result} with added information about which step failed.
359
- def call(input)
360
- runner = self.runner.new(steps)
160
+ # @return [Teckel::Chain::Result] The result object wrapping
161
+ # the result value, the success state and last executed step.
162
+ def call(input = nil)
163
+ runner = self.runner.new(self)
361
164
  if around
362
165
  around.call(runner, input)
363
166
  else
@@ -365,12 +168,23 @@ module Teckel
365
168
  end
366
169
  end
367
170
 
171
+ # @param settings [Hash{String,Symbol => Object}] Set settings for a step by it's name
172
+ def with(settings)
173
+ runner = self.runner.new(self, settings)
174
+ if around
175
+ ->(input) { around.call(runner, input) }
176
+ else
177
+ runner
178
+ end
179
+ end
180
+ alias :set :with
181
+
368
182
  # @!visibility private
369
183
  # @return [void]
370
184
  def define!
371
185
  raise MissingConfigError, "Cannot define Chain with no steps" if steps.empty?
372
186
 
373
- %i[around runner].each { |e| public_send(e) }
187
+ %i[around runner result result_constructor].each { |e| public_send(e) }
374
188
  steps.each(&:finalize!)
375
189
  nil
376
190
  end
@@ -384,7 +198,7 @@ module Teckel
384
198
  define!
385
199
  steps.freeze
386
200
  @config.freeze
387
- freeze
201
+ self
388
202
  end
389
203
 
390
204
  # Produces a shallow copy of this chain.
@@ -393,12 +207,7 @@ module Teckel
393
207
  # @return [self]
394
208
  # @!visibility public
395
209
  def dup
396
- super.tap do |copy|
397
- new_config = @config.dup
398
- new_config.replace(:steps) { steps.dup }
399
-
400
- copy.instance_variable_set(:@config, new_config)
401
- end
210
+ dup_config(super)
402
211
  end
403
212
 
404
213
  # Produces a clone of this chain.
@@ -410,22 +219,41 @@ module Teckel
410
219
  if frozen?
411
220
  super
412
221
  else
413
- super.tap do |copy|
414
- new_config = @config.dup
415
- new_config.replace(:steps) { steps.dup }
222
+ dup_config(super)
223
+ end
224
+ end
416
225
 
417
- copy.instance_variable_set(:@config, new_config)
418
- end
226
+ # @!visibility private
227
+ def inherited(subclass)
228
+ dup_config(subclass)
229
+ end
230
+
231
+ # @!visibility private
232
+ def self.extended(base)
233
+ base.instance_variable_set(:@config, Config.new)
234
+ end
235
+
236
+ private
237
+
238
+ def dup_config(other_class)
239
+ new_config = @config.dup
240
+ new_config.replace(:steps) { steps.dup }
241
+
242
+ other_class.instance_variable_set(:@config, new_config)
243
+ other_class
244
+ end
245
+
246
+ def build_counstructor(on, sym_or_proc)
247
+ if sym_or_proc.is_a?(Symbol) && on.respond_to?(sym_or_proc)
248
+ on.public_method(sym_or_proc)
249
+ elsif sym_or_proc.respond_to?(:call)
250
+ sym_or_proc
419
251
  end
420
252
  end
421
253
  end
422
254
 
423
255
  def self.included(receiver)
424
256
  receiver.extend ClassMethods
425
-
426
- receiver.class_eval do
427
- @config = Config.new
428
- end
429
257
  end
430
258
  end
431
259
  end