teckel 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/LICENSE_LOGO +4 -0
- data/README.md +4 -4
- data/lib/teckel.rb +9 -3
- data/lib/teckel/chain.rb +99 -271
- data/lib/teckel/chain/result.rb +38 -0
- data/lib/teckel/chain/runner.rb +51 -0
- data/lib/teckel/chain/step.rb +18 -0
- data/lib/teckel/config.rb +1 -23
- data/lib/teckel/contracts.rb +19 -0
- data/lib/teckel/operation.rb +309 -215
- data/lib/teckel/operation/result.rb +92 -0
- data/lib/teckel/operation/runner.rb +70 -0
- data/lib/teckel/result.rb +52 -53
- data/lib/teckel/version.rb +1 -1
- data/spec/chain/inheritance_spec.rb +116 -0
- data/spec/chain/results_spec.rb +53 -0
- data/spec/chain_around_hook_spec.rb +100 -0
- data/spec/chain_spec.rb +180 -0
- data/spec/config_spec.rb +26 -0
- data/spec/doctest_helper.rb +7 -0
- data/spec/operation/inheritance_spec.rb +94 -0
- data/spec/operation/result_spec.rb +34 -0
- data/spec/operation/results_spec.rb +117 -0
- data/spec/operation_spec.rb +485 -0
- data/spec/rb27/pattern_matching_spec.rb +193 -0
- data/spec/result_spec.rb +20 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/support/dry_base.rb +8 -0
- data/spec/support/fake_db.rb +12 -0
- data/spec/support/fake_models.rb +20 -0
- data/spec/teckel_spec.rb +7 -0
- metadata +52 -25
- data/.codeclimate.yml +0 -3
- data/.github/workflows/ci.yml +0 -92
- data/.github/workflows/pages.yml +0 -50
- data/.gitignore +0 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -12
- data/.ruby-version +0 -1
- data/DEVELOPMENT.md +0 -32
- data/Gemfile +0 -16
- data/Rakefile +0 -35
- data/bin/console +0 -15
- data/bin/rake +0 -29
- data/bin/rspec +0 -29
- data/bin/rubocop +0 -18
- data/bin/setup +0 -8
- data/lib/teckel/none.rb +0 -18
- data/lib/teckel/operation/results.rb +0 -72
- data/teckel.gemspec +0 -32
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Teckel
|
4
|
+
module Operation
|
5
|
+
# The optional, default result object for {Teckel::Operation}s.
|
6
|
+
# Wraps +output+ and +error+ into a {Teckel::Operation::Result}.
|
7
|
+
#
|
8
|
+
# @example
|
9
|
+
# class CreateUser
|
10
|
+
# include Teckel::Operation
|
11
|
+
#
|
12
|
+
# result! # Shortcut to use this Result object
|
13
|
+
#
|
14
|
+
# input Types::Hash.schema(name: Types::String, age: Types::Coercible::Integer)
|
15
|
+
# output Types.Instance(User)
|
16
|
+
# error Types::Hash.schema(message: Types::String, errors: Types::Array.of(Types::Hash))
|
17
|
+
#
|
18
|
+
# def call(input)
|
19
|
+
# user = User.new(name: input[:name], age: input[:age])
|
20
|
+
# if user.save
|
21
|
+
# success!(user) # exits early with success, prevents any further execution
|
22
|
+
# else
|
23
|
+
# fail!(message: "Could not save User", errors: user.errors)
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# # A success call:
|
29
|
+
# CreateUser.call(name: "Bob", age: 23).is_a?(Teckel::Operation::Result) #=> true
|
30
|
+
# CreateUser.call(name: "Bob", age: 23).success.is_a?(User) #=> true
|
31
|
+
#
|
32
|
+
# # A failure call:
|
33
|
+
# CreateUser.call(name: "Bob", age: 10).is_a?(Teckel::Operation::Result) #=> true
|
34
|
+
# CreateUser.call(name: "Bob", age: 10).failure.is_a?(Hash) #=> true
|
35
|
+
#
|
36
|
+
# @!visibility public
|
37
|
+
class Result
|
38
|
+
include Teckel::Result
|
39
|
+
|
40
|
+
# @param value [Object] The result value
|
41
|
+
# @param success [Boolean] whether this is a successful result
|
42
|
+
def initialize(value, success)
|
43
|
+
@value = value
|
44
|
+
@success = (!!success).freeze
|
45
|
+
end
|
46
|
+
|
47
|
+
# Whether this is a success result
|
48
|
+
# @return [Boolean]
|
49
|
+
def successful?
|
50
|
+
@success
|
51
|
+
end
|
52
|
+
|
53
|
+
# @!attribute [r] value
|
54
|
+
# @return [Mixed] the value/payload
|
55
|
+
attr_reader :value
|
56
|
+
|
57
|
+
# Get the error/failure value
|
58
|
+
# @yield [Mixed] If a block is given and this is not a failure result, the value is yielded to the block
|
59
|
+
# @param default [Mixed] return this default value if it's not a failure result
|
60
|
+
# @return [Mixed] the value/payload
|
61
|
+
def failure(default = nil, &block)
|
62
|
+
return @value unless @success
|
63
|
+
return yield(@value) if block
|
64
|
+
|
65
|
+
default
|
66
|
+
end
|
67
|
+
|
68
|
+
# Get the success value
|
69
|
+
# @yield [Mixed] If a block is given and this is not a success result, the value is yielded to the block
|
70
|
+
# @param default [Mixed] return this default value if it's not a success result
|
71
|
+
# @return [Mixed] the value/payload
|
72
|
+
def success(default = nil, &block)
|
73
|
+
return @value if @success
|
74
|
+
return yield(@value) if block
|
75
|
+
|
76
|
+
default
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# The default "no-op" Result handler. Just returns the value, ignoring the
|
81
|
+
# success state.
|
82
|
+
module ValueResult
|
83
|
+
class << self
|
84
|
+
def [](value, *_)
|
85
|
+
value
|
86
|
+
end
|
87
|
+
|
88
|
+
alias :new :[]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,70 @@
|
|
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
|
+
err = catch(:failure) do
|
20
|
+
simple_return = UNDEFINED
|
21
|
+
out = catch(:success) do
|
22
|
+
simple_return = call!(build_input(input))
|
23
|
+
end
|
24
|
+
return simple_return == UNDEFINED ? build_output(*out) : build_output(simple_return)
|
25
|
+
end
|
26
|
+
build_error(*err)
|
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
|
+
private
|
36
|
+
|
37
|
+
def call!(input)
|
38
|
+
op = @operation.new
|
39
|
+
op.settings = settings if settings != UNDEFINED
|
40
|
+
op.call(input)
|
41
|
+
end
|
42
|
+
|
43
|
+
def build_input(input)
|
44
|
+
operation.input_constructor.call(input)
|
45
|
+
end
|
46
|
+
|
47
|
+
def build_output(*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
|
+
operation.result_constructor.call(value, true)
|
56
|
+
end
|
57
|
+
|
58
|
+
def build_error(*args)
|
59
|
+
value =
|
60
|
+
if args.size == 1 && operation.error === args.first # rubocop:disable Style/CaseEquality
|
61
|
+
args.first
|
62
|
+
else
|
63
|
+
operation.error_constructor.call(*args)
|
64
|
+
end
|
65
|
+
|
66
|
+
operation.result_constructor.call(value, false)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/teckel/result.rb
CHANGED
@@ -1,63 +1,62 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Teckel
|
4
|
-
#
|
5
|
-
#
|
6
|
-
# @example
|
7
|
-
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
+
{}.tap do |e|
|
51
|
+
e[:success] = successful? if keys.include?(:success)
|
52
|
+
e[:value] = value if keys.include?(:value)
|
53
|
+
end
|
54
|
+
end
|
53
55
|
end
|
54
56
|
|
55
|
-
def
|
56
|
-
|
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
|
data/lib/teckel/version.rb
CHANGED
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'support/dry_base'
|
4
|
+
require 'support/fake_models'
|
5
|
+
|
6
|
+
RSpec.describe Teckel::Chain do
|
7
|
+
module TeckelChainDefaultsViaBaseClass
|
8
|
+
LOG = [] # rubocop:disable Style/MutableConstant
|
9
|
+
|
10
|
+
class LoggingChain
|
11
|
+
include Teckel::Chain
|
12
|
+
|
13
|
+
around do |chain, input|
|
14
|
+
require 'benchmark'
|
15
|
+
result = nil
|
16
|
+
LOG << Benchmark.measure { result = chain.call(input) }
|
17
|
+
result
|
18
|
+
end
|
19
|
+
|
20
|
+
freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
class OperationA
|
24
|
+
include Teckel::Operation
|
25
|
+
|
26
|
+
result!
|
27
|
+
|
28
|
+
input none
|
29
|
+
output Types::Integer
|
30
|
+
error none
|
31
|
+
|
32
|
+
def call(_)
|
33
|
+
rand(1000)
|
34
|
+
end
|
35
|
+
|
36
|
+
finalize!
|
37
|
+
end
|
38
|
+
|
39
|
+
class OperationB
|
40
|
+
include Teckel::Operation
|
41
|
+
|
42
|
+
result!
|
43
|
+
|
44
|
+
input none
|
45
|
+
output Types::String
|
46
|
+
error none
|
47
|
+
|
48
|
+
def call(_)
|
49
|
+
("a".."z").to_a.sample
|
50
|
+
end
|
51
|
+
|
52
|
+
finalize!
|
53
|
+
end
|
54
|
+
|
55
|
+
class ChainA < LoggingChain
|
56
|
+
step :roll, OperationA
|
57
|
+
|
58
|
+
finalize!
|
59
|
+
end
|
60
|
+
|
61
|
+
class ChainB < LoggingChain
|
62
|
+
step :say, OperationB
|
63
|
+
|
64
|
+
finalize!
|
65
|
+
end
|
66
|
+
|
67
|
+
class ChainC < ChainB
|
68
|
+
finalize!
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
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,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'support/dry_base'
|
4
|
+
require 'support/fake_models'
|
5
|
+
|
6
|
+
RSpec.describe Teckel::Chain do
|
7
|
+
module TeckelChainResultTest
|
8
|
+
class Message
|
9
|
+
include ::Teckel::Operation
|
10
|
+
|
11
|
+
result!
|
12
|
+
|
13
|
+
input Types::Hash.schema(message: Types::String)
|
14
|
+
error none
|
15
|
+
output Types::String
|
16
|
+
|
17
|
+
def call(input)
|
18
|
+
input[:message].upcase
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Chain
|
23
|
+
include Teckel::Chain
|
24
|
+
|
25
|
+
step :message, Message
|
26
|
+
|
27
|
+
class Result < Teckel::Operation::Result
|
28
|
+
def initialize(value, success, step, opts = {})
|
29
|
+
super(value, success)
|
30
|
+
@step = step
|
31
|
+
@opts = opts
|
32
|
+
end
|
33
|
+
|
34
|
+
class << self
|
35
|
+
alias :[] :new # Alias the default constructor to :new
|
36
|
+
end
|
37
|
+
|
38
|
+
attr_reader :opts, :step
|
39
|
+
end
|
40
|
+
|
41
|
+
result_constructor ->(value, success, step) {
|
42
|
+
result.new(value, success, step, time: Time.now.to_i)
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
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
|