teckel 0.2.0 → 0.7.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 +111 -0
- data/LICENSE_LOGO +4 -0
- data/README.md +4 -4
- data/lib/teckel.rb +9 -4
- data/lib/teckel/chain.rb +31 -341
- data/lib/teckel/chain/config.rb +275 -0
- data/lib/teckel/chain/result.rb +38 -0
- data/lib/teckel/chain/runner.rb +62 -0
- data/lib/teckel/chain/step.rb +18 -0
- data/lib/teckel/config.rb +25 -28
- data/lib/teckel/contracts.rb +19 -0
- data/lib/teckel/operation.rb +84 -302
- data/lib/teckel/operation/config.rb +396 -0
- data/lib/teckel/operation/result.rb +92 -0
- data/lib/teckel/operation/runner.rb +74 -0
- data/lib/teckel/result.rb +52 -53
- data/lib/teckel/version.rb +1 -1
- data/spec/chain/around_hook_spec.rb +100 -0
- data/spec/chain/default_settings_spec.rb +39 -0
- data/spec/chain/inheritance_spec.rb +116 -0
- data/spec/chain/none_input_spec.rb +36 -0
- data/spec/chain/results_spec.rb +53 -0
- data/spec/chain_spec.rb +180 -0
- data/spec/config_spec.rb +26 -0
- data/spec/doctest_helper.rb +8 -0
- data/spec/operation/contract_trace_spec.rb +116 -0
- data/spec/operation/default_settings_spec.rb +94 -0
- data/spec/operation/fail_on_input_spec.rb +103 -0
- data/spec/operation/inheritance_spec.rb +94 -0
- data/spec/operation/result_spec.rb +55 -0
- data/spec/operation/results_spec.rb +117 -0
- data/spec/operation_spec.rb +483 -0
- data/spec/rb27/pattern_matching_spec.rb +193 -0
- data/spec/result_spec.rb +22 -0
- data/spec/spec_helper.rb +28 -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 +68 -28
- 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,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
|
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
|
+
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
|
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,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
|