typed_operation 1.0.0.pre2 → 1.0.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/README.md +79 -574
- data/lib/generators/templates/operation.rb +2 -2
- data/lib/generators/typed_operation/install/USAGE +1 -0
- data/lib/generators/typed_operation/install/install_generator.rb +8 -0
- data/lib/generators/typed_operation/install/templates/application_operation.rb +24 -2
- data/lib/generators/typed_operation_generator.rb +8 -4
- data/lib/typed_operation/action_policy_auth.rb +161 -0
- data/lib/typed_operation/base.rb +5 -13
- data/lib/typed_operation/callable_resolver.rb +30 -0
- data/lib/typed_operation/chains/chained_operation.rb +27 -0
- data/lib/typed_operation/chains/fallback_chain.rb +32 -0
- data/lib/typed_operation/chains/map_chain.rb +37 -0
- data/lib/typed_operation/chains/sequence_chain.rb +54 -0
- data/lib/typed_operation/chains/smart_chain.rb +161 -0
- data/lib/typed_operation/chains/splat_chain.rb +53 -0
- data/lib/typed_operation/configuration.rb +52 -0
- data/lib/typed_operation/context.rb +193 -0
- data/lib/typed_operation/curried.rb +14 -1
- data/lib/typed_operation/explainable.rb +14 -0
- data/lib/typed_operation/immutable_base.rb +5 -2
- data/lib/typed_operation/instrumentation/trace.rb +71 -0
- data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
- data/lib/typed_operation/instrumentation.rb +214 -0
- data/lib/typed_operation/operations/composition.rb +41 -0
- data/lib/typed_operation/operations/executable.rb +55 -0
- data/lib/typed_operation/operations/introspection.rb +14 -8
- data/lib/typed_operation/operations/lifecycle.rb +5 -1
- data/lib/typed_operation/operations/parameters.rb +21 -6
- data/lib/typed_operation/operations/partial_application.rb +4 -0
- data/lib/typed_operation/operations/property_builder.rb +105 -0
- data/lib/typed_operation/partially_applied.rb +33 -10
- data/lib/typed_operation/pipeline/builder.rb +88 -0
- data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
- data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
- data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
- data/lib/typed_operation/pipeline.rb +176 -0
- data/lib/typed_operation/prepared.rb +13 -0
- data/lib/typed_operation/railtie.rb +4 -0
- data/lib/typed_operation/result/adapters/built_in.rb +28 -0
- data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
- data/lib/typed_operation/result/failure.rb +78 -0
- data/lib/typed_operation/result/mixin.rb +24 -0
- data/lib/typed_operation/result/success.rb +75 -0
- data/lib/typed_operation/result.rb +39 -0
- data/lib/typed_operation/version.rb +5 -1
- data/lib/typed_operation.rb +19 -6
- metadata +59 -18
- data/Rakefile +0 -17
- data/lib/tasks/typed_operation_tasks.rake +0 -4
- data/lib/typed_operation/operations/attribute_builder.rb +0 -75
- data/lib/typed_operation/operations/callable.rb +0 -27
- data/lib/typed_operation/operations/deconstruct.rb +0 -16
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Kwargs spreading chain created by .then_spreads
|
|
6
|
+
# Always splats the full context as **kwargs to the next operation.
|
|
7
|
+
# Accumulates result into context.
|
|
8
|
+
class SplatChain < ChainedOperation
|
|
9
|
+
include Result::Mixin
|
|
10
|
+
|
|
11
|
+
#: (*untyped, **untyped) -> _Result[untyped, untyped]
|
|
12
|
+
def call(...)
|
|
13
|
+
result = @left.call(...)
|
|
14
|
+
return result if result.failure?
|
|
15
|
+
|
|
16
|
+
left_value = result.value!
|
|
17
|
+
context = to_context(left_value)
|
|
18
|
+
|
|
19
|
+
right_result = @right.call(**context)
|
|
20
|
+
return right_result if right_result.failure?
|
|
21
|
+
|
|
22
|
+
# Merge right's output into context
|
|
23
|
+
right_value = right_result.value!
|
|
24
|
+
merged = merge_into_context(context, right_value)
|
|
25
|
+
|
|
26
|
+
Success(merged)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
#: (untyped) -> Hash[Symbol, untyped]
|
|
32
|
+
def to_context(value)
|
|
33
|
+
if value.respond_to?(:to_hash)
|
|
34
|
+
value.to_hash
|
|
35
|
+
elsif value.respond_to?(:to_h)
|
|
36
|
+
value.to_h
|
|
37
|
+
else
|
|
38
|
+
raise ArgumentError, "Cannot splat #{value.class} as kwargs - must respond to #to_hash or #to_h"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
|
|
43
|
+
def merge_into_context(context, value)
|
|
44
|
+
if value.respond_to?(:to_hash)
|
|
45
|
+
context.merge(value.to_hash)
|
|
46
|
+
elsif value.respond_to?(:to_h)
|
|
47
|
+
context.merge(value.to_h)
|
|
48
|
+
else
|
|
49
|
+
context.merge(_value: value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Global configuration for TypedOperation.
|
|
6
|
+
class Configuration
|
|
7
|
+
# @rbs @result_adapter: (Result::Adapters::BuiltIn | Result::Adapters::DryMonads | untyped)?
|
|
8
|
+
|
|
9
|
+
#: () -> void
|
|
10
|
+
def initialize
|
|
11
|
+
@result_adapter = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get the current result adapter. Defaults to BuiltIn.
|
|
15
|
+
#: () -> (Result::Adapters::BuiltIn | Result::Adapters::DryMonads | untyped)
|
|
16
|
+
def result_adapter
|
|
17
|
+
@result_adapter ||= Result::Adapters::BuiltIn.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Set the result adapter.
|
|
21
|
+
#: (:built_in | :dry_monads | untyped) -> void
|
|
22
|
+
def result_adapter=(adapter)
|
|
23
|
+
@result_adapter = case adapter
|
|
24
|
+
when :built_in then Result::Adapters::BuiltIn.new
|
|
25
|
+
when :dry_monads then Result::Adapters::DryMonads.new
|
|
26
|
+
else adapter
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @rbs @configuration: Configuration?
|
|
33
|
+
|
|
34
|
+
# Get the global configuration instance.
|
|
35
|
+
#: () -> Configuration
|
|
36
|
+
def configuration
|
|
37
|
+
@configuration ||= Configuration.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Configure TypedOperation with a block.
|
|
41
|
+
#: () { (Configuration) -> void } -> void
|
|
42
|
+
def configure
|
|
43
|
+
yield(configuration)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reset configuration to defaults. Mainly for testing.
|
|
47
|
+
#: () -> void
|
|
48
|
+
def reset_configuration!
|
|
49
|
+
@configuration = nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Context objects are for passing data through pipelines and chains.
|
|
6
|
+
#
|
|
7
|
+
# This particular implementation wraps a Hash but provides dot access for cleaner syntax.
|
|
8
|
+
# Its underlying store is mutable.
|
|
9
|
+
#
|
|
10
|
+
# Users can subclass or provide their own context that implements the same interface.
|
|
11
|
+
#
|
|
12
|
+
# Required interface for custom contexts:
|
|
13
|
+
# - #[](key) - read a value
|
|
14
|
+
# - #[]=(key, value) - write a value (if mutable)
|
|
15
|
+
# - #key?(key) - check if key exists
|
|
16
|
+
# - #to_h - convert to Hash (for kwargs extraction)
|
|
17
|
+
# - #merge(other) - combine with another context/hash, returns new context
|
|
18
|
+
#
|
|
19
|
+
# Dot access is not required. It's up to you if you prefer to have it in your operation usage.
|
|
20
|
+
#
|
|
21
|
+
class Context
|
|
22
|
+
# @rbs @data: Hash[Symbol, untyped]
|
|
23
|
+
|
|
24
|
+
#: (?Hash[Symbol | String, untyped] | Context | _ToH) -> void
|
|
25
|
+
def initialize(data = {})
|
|
26
|
+
@data = coerce_to_hash(data).transform_keys(&:to_sym)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: (Symbol | String) -> untyped
|
|
30
|
+
def [](key)
|
|
31
|
+
read_value(key.to_sym)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (Symbol | String, untyped) -> untyped
|
|
35
|
+
def []=(key, value)
|
|
36
|
+
write_value(key.to_sym, value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: (Symbol | String) -> bool
|
|
40
|
+
def key?(key)
|
|
41
|
+
@data.key?(key.to_sym)
|
|
42
|
+
end
|
|
43
|
+
alias_method :has_key?, :key?
|
|
44
|
+
|
|
45
|
+
#: () -> Hash[Symbol, untyped]
|
|
46
|
+
def to_h
|
|
47
|
+
@data.dup
|
|
48
|
+
end
|
|
49
|
+
alias_method :to_hash, :to_h
|
|
50
|
+
|
|
51
|
+
#: (Hash[Symbol | String, untyped] | Context | _ToH) -> Context
|
|
52
|
+
def merge(other)
|
|
53
|
+
self.class.new(@data.merge(coerce_to_hash(other)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Symbol | String) -> untyped
|
|
57
|
+
#: [T] (Symbol | String, T) -> (untyped | T)
|
|
58
|
+
#: [T] (Symbol | String) { (Symbol) -> T } -> (untyped | T)
|
|
59
|
+
def fetch(key, *args, &block)
|
|
60
|
+
@data.fetch(key.to_sym, *args, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: () -> Array[Symbol]
|
|
64
|
+
def keys
|
|
65
|
+
@data.keys
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> Array[untyped]
|
|
69
|
+
def values
|
|
70
|
+
@data.values
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#: () -> Enumerator[[Symbol, untyped], self]
|
|
74
|
+
#: () { ([Symbol, untyped]) -> void } -> self
|
|
75
|
+
def each(&block)
|
|
76
|
+
return enum_for(:each) unless block_given?
|
|
77
|
+
@data.each(&block)
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: () { (Symbol, untyped) -> boolish } -> Context
|
|
82
|
+
def select(&block)
|
|
83
|
+
self.class.new(@data.select(&block))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#: () { (Symbol, untyped) -> boolish } -> Context
|
|
87
|
+
def reject(&block)
|
|
88
|
+
self.class.new(@data.reject(&block))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: () { (untyped) -> untyped } -> Context
|
|
92
|
+
def transform_values(&block)
|
|
93
|
+
self.class.new(@data.transform_values(&block))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#: (*(Symbol | String)) -> Context
|
|
97
|
+
def slice(*keys)
|
|
98
|
+
self.class.new(@data.slice(*keys.map(&:to_sym)))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (*(Symbol | String)) -> Context
|
|
102
|
+
def except(*keys)
|
|
103
|
+
self.class.new(@data.except(*keys.map(&:to_sym)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: () -> bool
|
|
107
|
+
def empty?
|
|
108
|
+
@data.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#: () -> Integer
|
|
112
|
+
def size
|
|
113
|
+
@data.size
|
|
114
|
+
end
|
|
115
|
+
alias_method :length, :size
|
|
116
|
+
|
|
117
|
+
#: (untyped) -> bool
|
|
118
|
+
def ==(other)
|
|
119
|
+
case other
|
|
120
|
+
when Context
|
|
121
|
+
@data == other.to_h
|
|
122
|
+
when Hash
|
|
123
|
+
@data == other.transform_keys(&:to_sym)
|
|
124
|
+
else
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Provide dot access for reading and writing values.
|
|
130
|
+
#: (Symbol, *untyped) ?{ (*untyped) -> untyped } -> untyped
|
|
131
|
+
def method_missing(name, *args, &block)
|
|
132
|
+
name_str = name.to_s
|
|
133
|
+
|
|
134
|
+
if name_str.end_with?("=")
|
|
135
|
+
key = name_str.chomp("=").to_sym
|
|
136
|
+
write_value(key, args.first)
|
|
137
|
+
elsif name_str.end_with?("?")
|
|
138
|
+
key = name_str.chomp("?").to_sym
|
|
139
|
+
!!read_value(key)
|
|
140
|
+
elsif args.empty? && !block
|
|
141
|
+
read_value(name)
|
|
142
|
+
else
|
|
143
|
+
super
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
#: (Symbol, ?bool) -> bool
|
|
148
|
+
def respond_to_missing?(name, include_private = false)
|
|
149
|
+
name_str = name.to_s
|
|
150
|
+
name_str.end_with?("=", "?") ||
|
|
151
|
+
key?(name) ||
|
|
152
|
+
super
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
#: () -> String
|
|
156
|
+
def inspect
|
|
157
|
+
"#<#{self.class.name} #{@data.inspect}>"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
alias_method :to_s, :inspect
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
#: (Symbol) -> untyped
|
|
165
|
+
def read_value(key)
|
|
166
|
+
@data[key]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: (Symbol, untyped) -> untyped
|
|
170
|
+
def write_value(key, value)
|
|
171
|
+
@data[key] = value
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @rbs!
|
|
175
|
+
# interface _ToH
|
|
176
|
+
# def to_h: () -> Hash[untyped, untyped]
|
|
177
|
+
# end
|
|
178
|
+
|
|
179
|
+
#: (untyped) -> Hash[untyped, untyped]
|
|
180
|
+
def coerce_to_hash(obj)
|
|
181
|
+
case obj
|
|
182
|
+
when Hash
|
|
183
|
+
obj
|
|
184
|
+
when Context
|
|
185
|
+
obj.to_h
|
|
186
|
+
when ->(o) { o.respond_to?(:to_h) }
|
|
187
|
+
obj.to_h
|
|
188
|
+
else
|
|
189
|
+
raise ArgumentError, "Cannot coerce #{obj.class} to Hash"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module TypedOperation
|
|
5
|
+
# Wraps an operation to support automatic currying, allowing parameters to be
|
|
6
|
+
# provided one at a time. Returns a new Curried instance until all required
|
|
7
|
+
# parameters are satisfied, then executes the operation.
|
|
4
8
|
class Curried
|
|
9
|
+
# @rbs @operation_class: untyped
|
|
10
|
+
# @rbs @partial_operation: PartiallyApplied | Prepared
|
|
11
|
+
|
|
12
|
+
#: (untyped, ?(PartiallyApplied | Prepared)) -> void
|
|
5
13
|
def initialize(operation_class, partial_operation = nil)
|
|
6
14
|
@operation_class = operation_class
|
|
7
15
|
@partial_operation = partial_operation || operation_class.with
|
|
8
16
|
end
|
|
9
17
|
|
|
18
|
+
#: (untyped) -> (Curried | untyped)
|
|
10
19
|
def call(arg)
|
|
11
20
|
raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared?
|
|
12
21
|
|
|
13
22
|
next_partially_applied = if next_parameter_positional?
|
|
14
23
|
@partial_operation.with(arg)
|
|
15
24
|
else
|
|
16
|
-
|
|
25
|
+
key = next_keyword_parameter or raise ArgumentError, "No keyword parameter available"
|
|
26
|
+
@partial_operation.with(key => arg)
|
|
17
27
|
end
|
|
18
28
|
if next_partially_applied.prepared?
|
|
19
29
|
next_partially_applied.call
|
|
@@ -22,17 +32,20 @@ module TypedOperation
|
|
|
22
32
|
end
|
|
23
33
|
end
|
|
24
34
|
|
|
35
|
+
#: () -> Proc
|
|
25
36
|
def to_proc
|
|
26
37
|
method(:call).to_proc
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
private
|
|
30
41
|
|
|
42
|
+
#: () -> Symbol?
|
|
31
43
|
def next_keyword_parameter
|
|
32
44
|
remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys
|
|
33
45
|
remaining.first
|
|
34
46
|
end
|
|
35
47
|
|
|
48
|
+
#: () -> bool
|
|
36
49
|
def next_parameter_positional?
|
|
37
50
|
@partial_operation.positional_args.size < @operation_class.required_positional_parameters.size
|
|
38
51
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
# Include this module in your operation base class to enable the `.explain` method
|
|
6
|
+
# for runtime tracing and debugging.
|
|
7
|
+
module Explainable
|
|
8
|
+
#: (Module) -> void
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(Instrumentation::Explainable)
|
|
11
|
+
base.prepend(Instrumentation::Traceable)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module TypedOperation
|
|
5
|
+
# Immutable base class for operations, built on Literal::Data.
|
|
6
|
+
# Use this when operation state should not be modified after initialization.
|
|
4
7
|
class ImmutableBase < Literal::Data
|
|
5
8
|
extend Operations::Introspection
|
|
6
9
|
extend Operations::Parameters
|
|
7
10
|
extend Operations::PartialApplication
|
|
11
|
+
extend Operations::Composition
|
|
8
12
|
|
|
9
|
-
include Operations::Callable
|
|
10
13
|
include Operations::Lifecycle
|
|
11
|
-
include Operations::
|
|
14
|
+
include Operations::Executable
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
module Instrumentation
|
|
6
|
+
# Represents a single operation execution trace with timing and result data.
|
|
7
|
+
# Supports nested traces for operations that call other operations.
|
|
8
|
+
class Trace
|
|
9
|
+
attr_reader :operation_class #: untyped
|
|
10
|
+
attr_reader :params #: Hash[Symbol, untyped]
|
|
11
|
+
attr_reader :start_time #: Float
|
|
12
|
+
attr_reader :children #: Array[Trace]
|
|
13
|
+
attr_accessor :end_time #: Float?
|
|
14
|
+
attr_accessor :result #: untyped
|
|
15
|
+
attr_accessor :exception #: Exception?
|
|
16
|
+
attr_accessor :pass_mode #: Symbol?
|
|
17
|
+
attr_accessor :extracted_params #: Array[Symbol]?
|
|
18
|
+
attr_accessor :fallback_used #: bool
|
|
19
|
+
|
|
20
|
+
#: (operation_class: untyped, ?params: Hash[Symbol, untyped]) -> void
|
|
21
|
+
def initialize(operation_class:, params: {})
|
|
22
|
+
@operation_class = operation_class
|
|
23
|
+
@params = params
|
|
24
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
@end_time = nil
|
|
26
|
+
@result = nil
|
|
27
|
+
@exception = nil
|
|
28
|
+
@children = []
|
|
29
|
+
@pass_mode = nil
|
|
30
|
+
@extracted_params = nil
|
|
31
|
+
@fallback_used = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (?result: untyped, ?exception: Exception?) -> self
|
|
35
|
+
def finish!(result: nil, exception: nil)
|
|
36
|
+
@end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
@result = result
|
|
38
|
+
@exception = exception
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: () -> (Float | Integer | nil)
|
|
43
|
+
def duration_ms
|
|
44
|
+
return nil unless end_time
|
|
45
|
+
((end_time - start_time) * 1000).round(2)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: () -> bool
|
|
49
|
+
def success?
|
|
50
|
+
return false if exception
|
|
51
|
+
return result.success? if result.respond_to?(:success?)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> bool
|
|
56
|
+
def failure?
|
|
57
|
+
!success?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (Trace) -> void
|
|
61
|
+
def add_child(trace)
|
|
62
|
+
@children << trace
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> String
|
|
66
|
+
def operation_name
|
|
67
|
+
operation_class.name || operation_class.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module TypedOperation
|
|
5
|
+
module Instrumentation
|
|
6
|
+
# Formats execution traces as a visual chain for console output.
|
|
7
|
+
# Shows operation flow with arrows and pass mode indicators.
|
|
8
|
+
class TreeFormatter
|
|
9
|
+
ARROW = "→" #: String
|
|
10
|
+
FALLBACK_ARROW = "⤷" #: String
|
|
11
|
+
|
|
12
|
+
SUCCESS_MARK = "✓" #: String
|
|
13
|
+
FAILURE_MARK = "✗" #: String
|
|
14
|
+
SKIPPED_MARK = "○" #: String
|
|
15
|
+
|
|
16
|
+
PASS_MODES = {
|
|
17
|
+
kwargs_splat: "**ctx",
|
|
18
|
+
single_value: "ctx",
|
|
19
|
+
transform: "transform",
|
|
20
|
+
fallback: "fallback"
|
|
21
|
+
}.freeze #: Hash[Symbol, String]
|
|
22
|
+
|
|
23
|
+
COLORS = {
|
|
24
|
+
green: "\e[32m",
|
|
25
|
+
red: "\e[31m",
|
|
26
|
+
yellow: "\e[33m",
|
|
27
|
+
cyan: "\e[36m",
|
|
28
|
+
dim: "\e[2m",
|
|
29
|
+
bold: "\e[1m",
|
|
30
|
+
reset: "\e[0m"
|
|
31
|
+
}.freeze #: Hash[Symbol, String]
|
|
32
|
+
|
|
33
|
+
# @rbs @color: bool
|
|
34
|
+
|
|
35
|
+
#: (?color: bool?) -> void
|
|
36
|
+
def initialize(color: nil)
|
|
37
|
+
@color = color.nil? ? detect_color_support : color
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (Trace) -> String
|
|
41
|
+
def format(trace)
|
|
42
|
+
lines = [] #: Array[String]
|
|
43
|
+
format_chain(trace, lines)
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
#: (Trace, Array[String], ?depth: Integer, ?is_fallback: bool) -> void
|
|
50
|
+
def format_chain(trace, lines, depth: 0, is_fallback: false)
|
|
51
|
+
indent = " " * depth
|
|
52
|
+
|
|
53
|
+
status = format_status(trace)
|
|
54
|
+
duration = format_duration(trace.duration_ms)
|
|
55
|
+
pass_info = format_pass_mode(trace)
|
|
56
|
+
|
|
57
|
+
op_name = trace.operation_name
|
|
58
|
+
if is_fallback
|
|
59
|
+
op_name = "#{colorize(FALLBACK_ARROW, :yellow)} #{op_name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
line = "#{indent}#{op_name} #{duration} #{status}"
|
|
63
|
+
|
|
64
|
+
if pass_info
|
|
65
|
+
line = "#{line} #{pass_info}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
extracted = trace.extracted_params
|
|
69
|
+
if extracted&.any?
|
|
70
|
+
params_str = extracted.join(", ")
|
|
71
|
+
line = "#{line} #{colorize("← [#{params_str}]", :dim)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lines << line
|
|
75
|
+
|
|
76
|
+
if trace.exception
|
|
77
|
+
lines << "#{indent} #{colorize("└ Exception: #{trace.exception.class}: #{trace.exception.message}", :red)}"
|
|
78
|
+
elsif trace.failure? && trace.result.respond_to?(:failure)
|
|
79
|
+
failure_info = trace.result.failure
|
|
80
|
+
lines << "#{indent} #{colorize("└ Failure: #{failure_info.inspect}", :red)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
trace.children.each_with_index do |child, index|
|
|
84
|
+
is_child_fallback = child.fallback_used
|
|
85
|
+
|
|
86
|
+
if index == 0 || is_child_fallback
|
|
87
|
+
arrow = is_child_fallback ? FALLBACK_ARROW : ARROW
|
|
88
|
+
arrow_color = is_child_fallback ? :yellow : :dim
|
|
89
|
+
lines << "#{indent} #{colorize(arrow, arrow_color)}"
|
|
90
|
+
else
|
|
91
|
+
lines << "#{indent} #{colorize(ARROW, :dim)}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
format_chain(child, lines, depth: depth, is_fallback: is_child_fallback)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: (Trace) -> String
|
|
99
|
+
def format_status(trace)
|
|
100
|
+
if trace.exception
|
|
101
|
+
colorize(FAILURE_MARK, :red)
|
|
102
|
+
elsif trace.failure?
|
|
103
|
+
colorize(FAILURE_MARK, :red)
|
|
104
|
+
else
|
|
105
|
+
colorize(SUCCESS_MARK, :green)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (Float | Integer | nil) -> String
|
|
110
|
+
def format_duration(ms)
|
|
111
|
+
return colorize("[--ms]", :dim) unless ms
|
|
112
|
+
colorize("[#{ms}ms]", :dim)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (Trace) -> String?
|
|
116
|
+
def format_pass_mode(trace)
|
|
117
|
+
return nil unless trace.pass_mode
|
|
118
|
+
mode_str = PASS_MODES[trace.pass_mode] || trace.pass_mode.to_s
|
|
119
|
+
colorize("(#{mode_str})", :cyan)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (Exception?) -> String
|
|
123
|
+
def format_exception(exception)
|
|
124
|
+
return "" unless exception
|
|
125
|
+
colorize("(#{exception.class}: #{exception.message})", :red)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (String, Symbol) -> String
|
|
129
|
+
def colorize(text, color)
|
|
130
|
+
return text unless @color
|
|
131
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#: () -> bool
|
|
135
|
+
def detect_color_support
|
|
136
|
+
return false unless $stdout.respond_to?(:tty?)
|
|
137
|
+
$stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|