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,74 @@
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(:halt) do
20
+ op = instance
21
+ op_input = op.instance_exec(input, &operation.input_constructor)
22
+ op.call(op_input)
23
+ nil # return values need to go through +success!+ or +fail!+
24
+ end
25
+ end
26
+
27
+ def instance
28
+ return @instance if instance_variable_defined?(:@instance)
29
+
30
+ op = operation.new
31
+ op.runner = self
32
+ op.settings = settings if settings != UNDEFINED
33
+
34
+ @instance = op
35
+ end
36
+
37
+ # This is just here to raise a meaningful error.
38
+ # @!visibility private
39
+ def with(*)
40
+ raise Teckel::Error, "Operation already has settings assigned."
41
+ end
42
+
43
+ # Halt any further execution with a output value
44
+ #
45
+ # @return a thing matching your {Teckel::Operation::Config#output output} definition
46
+ # @!visibility protected
47
+ def success!(*args)
48
+ value =
49
+ if args.size == 1 && operation.output === args.first # rubocop:disable Style/CaseEquality
50
+ args.first
51
+ else
52
+ operation.output_constructor.call(*args)
53
+ end
54
+
55
+ throw :halt, instance.instance_exec(value, true, &operation.result_constructor)
56
+ end
57
+
58
+ # Halt any further execution with an error value
59
+ #
60
+ # @return a thing matching your {Teckel::Operation::Config#error error} definition
61
+ # @!visibility protected
62
+ def fail!(*args)
63
+ value =
64
+ if args.size == 1 && operation.error === args.first # rubocop:disable Style/CaseEquality
65
+ args.first
66
+ else
67
+ operation.error_constructor.call(*args)
68
+ end
69
+
70
+ throw :halt, instance.instance_exec(value, false, &operation.result_constructor)
71
+ end
72
+ end
73
+ end
74
+ 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
- # @!visibility 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).freeze
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.2.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'support/dry_base'
4
+ require 'support/fake_db'
5
+ require 'support/fake_models'
6
+
7
+ module TeckelChainAroundHookTest
8
+ class CreateUser
9
+ include ::Teckel::Operation
10
+
11
+ result!
12
+
13
+ input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer.optional)
14
+ output Types.Instance(User)
15
+ error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
16
+
17
+ def call(input)
18
+ user = User.new(name: input[:name], age: input[:age])
19
+ if user.save
20
+ success!(user)
21
+ else
22
+ fail!(message: "Could not safe User", errors: user.errors)
23
+ end
24
+ end
25
+ end
26
+
27
+ class AddFriend
28
+ include ::Teckel::Operation
29
+
30
+ result!
31
+
32
+ settings Struct.new(:fail_befriend)
33
+
34
+ input Types.Instance(User)
35
+ output Types::Hash.schema(user: Types.Instance(User), friend: Types.Instance(User))
36
+ error Types::Hash.schema(message: Types::String)
37
+
38
+ def call(user)
39
+ if settings&.fail_befriend
40
+ fail!(message: "Did not find a friend.")
41
+ else
42
+ success! user: user, friend: User.new(name: "A friend", age: 42)
43
+ end
44
+ end
45
+ end
46
+
47
+ @stack = []
48
+ def self.stack
49
+ @stack
50
+ end
51
+
52
+ class Chain
53
+ include Teckel::Chain
54
+
55
+ around ->(chain, input) {
56
+ result = nil
57
+ begin
58
+ TeckelChainAroundHookTest.stack << :before
59
+
60
+ FakeDB.transaction do
61
+ result = chain.call(input)
62
+ raise FakeDB::Rollback if result.failure?
63
+ end
64
+
65
+ TeckelChainAroundHookTest.stack << :after
66
+ result
67
+ rescue FakeDB::Rollback
68
+ result
69
+ end
70
+ }
71
+
72
+ step :create, CreateUser
73
+ step :befriend, AddFriend
74
+ end
75
+ end
76
+
77
+ RSpec.describe Teckel::Chain do
78
+ before { TeckelChainAroundHookTest.stack.clear }
79
+
80
+ context "success" do
81
+ it "result matches" do
82
+ result = TeckelChainAroundHookTest::Chain.call(name: "Bob", age: 23)
83
+ expect(result.success).to include(user: kind_of(User), friend: kind_of(User))
84
+ end
85
+
86
+ it "runs around hook" do
87
+ TeckelChainAroundHookTest::Chain.call(name: "Bob", age: 23)
88
+ expect(TeckelChainAroundHookTest.stack).to eq([:before, :after])
89
+ end
90
+ end
91
+
92
+ context "failure" do
93
+ it "runs around hook" do
94
+ TeckelChainAroundHookTest::Chain.
95
+ with(befriend: :fail).
96
+ call(name: "Bob", age: 23)
97
+ expect(TeckelChainAroundHookTest.stack).to eq([:before])
98
+ end
99
+ end
100
+ 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