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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teckel
4
+ module Operation
5
+ # The default implementation for executing a single {Operation}
6
+ # @note You shouldn't need to call this explicitly.
7
+ # Use {ClassMethods#with MyOperation.with()} or {ClassMethods#with MyOperation.call()} instead.
8
+ # @!visibility protected
9
+ class Runner
10
+ # @!visibility private
11
+ UNDEFINED = Object.new.freeze
12
+
13
+ def initialize(operation, settings = UNDEFINED)
14
+ @operation, @settings = operation, settings
15
+ end
16
+ attr_reader :operation, :settings
17
+
18
+ def call(input = nil)
19
+ catch(:failure) do
20
+ out = catch(:success) do
21
+ run operation.input_constructor.call(input)
22
+ return nil # :sic!: return values need to go through +success!+
23
+ end
24
+
25
+ return out
26
+ end
27
+ end
28
+
29
+ # This is just here to raise a meaningful error.
30
+ # @!visibility private
31
+ def with(*)
32
+ raise Teckel::Error, "Operation already has settings assigned."
33
+ end
34
+
35
+ # Halt any further execution with a output value
36
+ #
37
+ # @return a thing matching your {Teckel::Operation::Config#output output} definition
38
+ # @!visibility protected
39
+ def success!(*args)
40
+ value =
41
+ if args.size == 1 && operation.output === args.first # rubocop:disable Style/CaseEquality
42
+ args.first
43
+ else
44
+ operation.output_constructor.call(*args)
45
+ end
46
+
47
+ throw :success, operation.result_constructor.call(value, true)
48
+ end
49
+
50
+ # Halt any further execution with an error value
51
+ #
52
+ # @return a thing matching your {Teckel::Operation::Config#error error} definition
53
+ # @!visibility protected
54
+ def fail!(*args)
55
+ value =
56
+ if args.size == 1 && operation.error === args.first # rubocop:disable Style/CaseEquality
57
+ args.first
58
+ else
59
+ operation.error_constructor.call(*args)
60
+ end
61
+
62
+ throw :failure, operation.result_constructor.call(value, false)
63
+ end
64
+
65
+ private
66
+
67
+ def run(input)
68
+ op = @operation.new
69
+ op.runner = self
70
+ op.settings = settings if settings != UNDEFINED
71
+ op.call(input)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,63 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teckel
4
- # Wrapper for +output+ and +error+ return values of Operations
5
- #
6
- # @example asking for status
7
- #
8
- # Teckel::Result.new("some output", true).successful? #=> true
9
- # Teckel::Result.new("some output", true).failure? #=> false
10
- #
11
- # Teckel::Result.new("some error", false).successful? #=> false
12
- # Teckel::Result.new("some error", false).failure? #=> true
13
- #
14
- # @example Use +.value+ to get the wrapped value regardless of success state
15
- #
16
- # Teckel::Result.new("some output", true).value #=> "some output"
17
- # Teckel::Result.new("some error", false).value #=> "some error"
18
- #
19
- # @example Use +.success+ to get the wrapped value of a successful result
20
- #
21
- # # Note: The +.failure+ method works just the same for successful results
22
- # Teckel::Result.new("some output", true).success #=> "some output"
23
- # Teckel::Result.new("some error", false).success #=> nil
24
- # Teckel::Result.new("some error", false).success("other default") #=> "other default"
25
- # Teckel::Result.new("some error", false).success { |value| "Failed: #{value}" } #=> "Failed: some error"
26
- #
27
- # @api public
28
- class Result
29
- # @param value [Mixed] the value/payload of the result.
30
- # @param success [Bool] whether this is a successful result
31
- def initialize(value, success)
32
- @value = value
33
- @success = success
4
+ # @abstract The interface an {Operation}s result object needs to adopt.
5
+ #
6
+ # @example
7
+ # class MyResult
8
+ # include Teckel::Result
9
+ #
10
+ # def initialize(value, success)
11
+ # @value = value
12
+ # @success = (!!success).freeze
13
+ # end
14
+ #
15
+ # def successful?; @success end
16
+ #
17
+ # def value; @value end
18
+ # end
19
+ module Result
20
+ module ClassMethods
21
+ def [](value, success)
22
+ new(value, success)
23
+ end
34
24
  end
35
25
 
36
- # @!attribute [r] value
37
- # @return [Mixed] the value/payload
38
- attr_reader :value
39
-
40
- def successful?
41
- @success
42
- end
43
-
44
- def failure?
45
- !@success
46
- end
47
-
48
- def failure(default = nil, &block)
49
- return @value if !@success
50
- return yield(@value) if block
51
-
52
- default
26
+ module InstanceMethods
27
+ # Whether this is a success result
28
+ # @return [Boolean]
29
+ def successful?
30
+ raise NotImplementedError, "Result object does not implement `successful?`"
31
+ end
32
+
33
+ # Whether this is a error/failure result
34
+ # @return [Boolean]
35
+ def failure?
36
+ !successful?
37
+ end
38
+
39
+ # @!attribute [r] value
40
+ # @return [Mixed] the value/payload
41
+ def value
42
+ raise NotImplementedError, "Result object does not implement `value`"
43
+ end
44
+
45
+ def deconstruct
46
+ [successful?, value]
47
+ end
48
+
49
+ def deconstruct_keys(keys)
50
+ e = {}
51
+ e[:success] = successful? if keys.include?(:success)
52
+ e[:value] = value if keys.include?(:value)
53
+ e
54
+ end
53
55
  end
54
56
 
55
- def success(default = nil, &block)
56
- return @value if @success
57
-
58
- return yield(@value) if block
59
-
60
- default
57
+ def self.included(receiver)
58
+ receiver.extend ClassMethods
59
+ receiver.send :include, InstanceMethods
61
60
  end
62
61
  end
63
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teckel
4
- VERSION = "0.1.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TeckelChainDefaultSettingsTest
4
+ class MyOperation
5
+ include Teckel::Operation
6
+ result!
7
+
8
+ settings Struct.new(:say, :other)
9
+ settings_constructor ->(data) { settings.new(*data.values_at(*settings.members)) } # ruby 2.4 way for `keyword_init: true`
10
+
11
+ input none
12
+ output Hash
13
+ error none
14
+
15
+ def call(_)
16
+ success! settings.to_h
17
+ end
18
+ end
19
+
20
+ class Chain
21
+ include Teckel::Chain
22
+
23
+ default_settings!(a: { say: "Chain Default" })
24
+
25
+ step :a, MyOperation
26
+ end
27
+ end
28
+
29
+ RSpec.describe Teckel::Chain do
30
+ specify "call chain without settings, uses default settings" do
31
+ result = TeckelChainDefaultSettingsTest::Chain.call
32
+ expect(result.success).to eq(say: "Chain Default", other: nil)
33
+ end
34
+
35
+ specify "call chain with explicit settings, overwrites defaults" do
36
+ result = TeckelChainDefaultSettingsTest::Chain.with(a: { other: "What" }).call
37
+ expect(result.success).to eq(say: nil, other: "What")
38
+ end
39
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_models'
5
+
6
+ module TeckelChainDefaultsViaBaseClass
7
+ LOG = [] # rubocop:disable Style/MutableConstant
8
+
9
+ class LoggingChain
10
+ include Teckel::Chain
11
+
12
+ around do |chain, input|
13
+ require 'benchmark'
14
+ result = nil
15
+ LOG << Benchmark.measure { result = chain.call(input) }
16
+ result
17
+ end
18
+
19
+ freeze
20
+ end
21
+
22
+ class OperationA
23
+ include Teckel::Operation
24
+
25
+ result!
26
+
27
+ input none
28
+ output Types::Integer
29
+ error none
30
+
31
+ def call(_)
32
+ success! rand(1000)
33
+ end
34
+
35
+ finalize!
36
+ end
37
+
38
+ class OperationB
39
+ include Teckel::Operation
40
+
41
+ result!
42
+
43
+ input none
44
+ output Types::String
45
+ error none
46
+
47
+ def call(_)
48
+ success! ("a".."z").to_a.sample
49
+ end
50
+
51
+ finalize!
52
+ end
53
+
54
+ class ChainA < LoggingChain
55
+ step :roll, OperationA
56
+
57
+ finalize!
58
+ end
59
+
60
+ class ChainB < LoggingChain
61
+ step :say, OperationB
62
+
63
+ finalize!
64
+ end
65
+
66
+ class ChainC < ChainB
67
+ finalize!
68
+ end
69
+ end
70
+
71
+ RSpec.describe Teckel::Chain do
72
+ before do
73
+ TeckelChainDefaultsViaBaseClass::LOG.clear
74
+ end
75
+
76
+ let(:base_chain) { TeckelChainDefaultsViaBaseClass::LoggingChain }
77
+ let(:chain_a) { TeckelChainDefaultsViaBaseClass::ChainA }
78
+ let(:chain_b) { TeckelChainDefaultsViaBaseClass::ChainB }
79
+ let(:chain_c) { TeckelChainDefaultsViaBaseClass::ChainC }
80
+
81
+ it "inherits config" do
82
+ expect(chain_a.around)
83
+ expect(chain_b.around)
84
+
85
+ expect(base_chain.steps).to eq([])
86
+ expect(chain_a.steps.size).to eq(1)
87
+ expect(chain_b.steps.size).to eq(1)
88
+ end
89
+
90
+ it "runs chain a" do
91
+ expect {
92
+ result = chain_a.call
93
+ expect(result.success).to be_a(Integer)
94
+ }.to change {
95
+ TeckelChainDefaultsViaBaseClass::LOG.size
96
+ }.from(0).to(1)
97
+ end
98
+
99
+ it "runs chain b" do
100
+ expect {
101
+ result = chain_b.call
102
+ expect(result.success).to be_a(String)
103
+ }.to change {
104
+ TeckelChainDefaultsViaBaseClass::LOG.size
105
+ }.from(0).to(1)
106
+ end
107
+
108
+ it "inherits steps" do
109
+ expect {
110
+ result = chain_c.call
111
+ expect(result.success).to be_a(String)
112
+ }.to change {
113
+ TeckelChainDefaultsViaBaseClass::LOG.size
114
+ }.from(0).to(1)
115
+ end
116
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TeckelChainNoneInputTest
4
+ class MyOperation
5
+ include Teckel::Operation
6
+ result!
7
+
8
+ settings Struct.new(:say)
9
+
10
+ input none
11
+ output String
12
+ error none
13
+
14
+ def call(_)
15
+ success!(settings&.say || "Called")
16
+ end
17
+ end
18
+
19
+ class Chain
20
+ include Teckel::Chain
21
+
22
+ step :a, MyOperation
23
+ end
24
+ end
25
+
26
+ RSpec.describe Teckel::Chain do
27
+ specify "call chain without input value" do
28
+ result = TeckelChainNoneInputTest::Chain.call
29
+ expect(result.success).to eq("Called")
30
+ end
31
+
32
+ specify "call chain runner without input value" do
33
+ result = TeckelChainNoneInputTest::Chain.with(a: "What").call
34
+ expect(result.success).to eq("What")
35
+ end
36
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_models'
5
+
6
+ module TeckelChainResultTest
7
+ class Message
8
+ include ::Teckel::Operation
9
+
10
+ result!
11
+
12
+ input Types::Hash.schema(message: Types::String)
13
+ error none
14
+ output Types::String
15
+
16
+ def call(input)
17
+ success! input[:message].upcase
18
+ end
19
+ end
20
+
21
+ class Chain
22
+ include Teckel::Chain
23
+
24
+ step :message, Message
25
+
26
+ class Result < Teckel::Operation::Result
27
+ def initialize(value, success, step, opts = {})
28
+ super(value, success)
29
+ @step = step
30
+ @opts = opts
31
+ end
32
+
33
+ class << self
34
+ alias :[] :new # Alias the default constructor to :new
35
+ end
36
+
37
+ attr_reader :opts, :step
38
+ end
39
+
40
+ result_constructor ->(value, success, step) {
41
+ result.new(value, success, step, time: Time.now.to_i)
42
+ }
43
+ end
44
+ end
45
+
46
+ RSpec.describe Teckel::Chain do
47
+ specify do
48
+ result = TeckelChainResultTest::Chain.call(message: "Hello World!")
49
+ expect(result).to be_successful
50
+ expect(result.success).to eq("HELLO WORLD!")
51
+ expect(result.opts).to include(time: kind_of(Integer))
52
+ end
53
+ end