teckel 0.2.0 → 0.7.0

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