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