typed_operation 1.0.0.beta3 → 1.0.0.beta4

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -724
  3. data/lib/generators/typed_operation/install/install_generator.rb +3 -0
  4. data/lib/generators/typed_operation_generator.rb +8 -4
  5. data/lib/typed_operation/action_policy_auth.rb +44 -24
  6. data/lib/typed_operation/base.rb +4 -1
  7. data/lib/typed_operation/callable_resolver.rb +30 -0
  8. data/lib/typed_operation/chains/chained_operation.rb +27 -0
  9. data/lib/typed_operation/chains/fallback_chain.rb +32 -0
  10. data/lib/typed_operation/chains/map_chain.rb +37 -0
  11. data/lib/typed_operation/chains/sequence_chain.rb +54 -0
  12. data/lib/typed_operation/chains/smart_chain.rb +161 -0
  13. data/lib/typed_operation/chains/splat_chain.rb +53 -0
  14. data/lib/typed_operation/configuration.rb +52 -0
  15. data/lib/typed_operation/context.rb +193 -0
  16. data/lib/typed_operation/curried.rb +14 -1
  17. data/lib/typed_operation/explainable.rb +14 -0
  18. data/lib/typed_operation/immutable_base.rb +4 -1
  19. data/lib/typed_operation/instrumentation/trace.rb +71 -0
  20. data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
  21. data/lib/typed_operation/instrumentation.rb +214 -0
  22. data/lib/typed_operation/operations/composition.rb +41 -0
  23. data/lib/typed_operation/operations/executable.rb +27 -1
  24. data/lib/typed_operation/operations/introspection.rb +9 -1
  25. data/lib/typed_operation/operations/lifecycle.rb +4 -1
  26. data/lib/typed_operation/operations/parameters.rb +11 -5
  27. data/lib/typed_operation/operations/partial_application.rb +4 -0
  28. data/lib/typed_operation/operations/property_builder.rb +46 -22
  29. data/lib/typed_operation/partially_applied.rb +33 -10
  30. data/lib/typed_operation/pipeline/builder.rb +88 -0
  31. data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
  32. data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
  33. data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
  34. data/lib/typed_operation/pipeline.rb +176 -0
  35. data/lib/typed_operation/prepared.rb +13 -0
  36. data/lib/typed_operation/railtie.rb +4 -0
  37. data/lib/typed_operation/result/adapters/built_in.rb +28 -0
  38. data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
  39. data/lib/typed_operation/result/failure.rb +78 -0
  40. data/lib/typed_operation/result/mixin.rb +24 -0
  41. data/lib/typed_operation/result/success.rb +75 -0
  42. data/lib/typed_operation/result.rb +39 -0
  43. data/lib/typed_operation/version.rb +5 -1
  44. data/lib/typed_operation.rb +18 -1
  45. metadata +27 -3
  46. data/lib/typed_operation/operations/callable.rb +0 -23
@@ -3,7 +3,10 @@
3
3
  require "rails/generators/base"
4
4
 
5
5
  module TypedOperation
6
+ # Contains installation-related generators for TypedOperation.
6
7
  module Install
8
+ # Rails generator for installing TypedOperation into a Rails application.
9
+ # Creates an ApplicationOperation base class with optional dry-monads or action_policy support.
7
10
  class InstallGenerator < Rails::Generators::Base
8
11
  class_option :dry_monads, type: :boolean, default: false
9
12
  class_option :action_policy, type: :boolean, default: false
@@ -2,19 +2,23 @@
2
2
 
3
3
  require "rails/generators/named_base"
4
4
 
5
+ # Rails generator for creating new typed operation files and their tests.
5
6
  class TypedOperationGenerator < Rails::Generators::NamedBase
6
7
  source_root File.expand_path("templates", __dir__)
7
8
 
8
9
  class_option :path, type: :string, default: "app/operations"
9
10
 
10
11
  def generate_operation
12
+ source_root = self.class.source_root
13
+ operation_path = options[:path]
14
+
11
15
  template(
12
- File.join(self.class.source_root, "operation.rb"),
13
- File.join(options[:path], "#{file_name}.rb")
16
+ File.join(source_root, "operation.rb"),
17
+ File.join(operation_path, "#{file_name}.rb")
14
18
  )
15
19
  template(
16
- File.join(self.class.source_root, "operation_test.rb"),
17
- File.join("test/", options[:path].gsub(/\Aapp\//, ""), "#{file_name}_test.rb")
20
+ File.join(source_root, "operation_test.rb"),
21
+ File.join("test/", operation_path.gsub(/\Aapp\//, ""), "#{file_name}_test.rb")
18
22
  )
19
23
  end
20
24
 
@@ -1,13 +1,14 @@
1
+ # rbs_inline: enabled
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require "action_policy"
4
5
 
5
- # An optional module to include in your operation to enable authorization via ActionPolicies
6
+ # An optional module to include in your operation to enable authorization via ActionPolicies.
6
7
  module TypedOperation
7
8
  class MissingAuthentication < StandardError; end
8
9
 
9
10
  module ActionPolicyAuth
10
- # Base class for any action policy classes used by operations
11
+ # Base class for any action policy classes used by operations.
11
12
  class OperationPolicy
12
13
  include ActionPolicy::Policy::Core
13
14
  include ActionPolicy::Policy::Authorization
@@ -19,15 +20,24 @@ module TypedOperation
19
20
  include ActionPolicy::Policy::CachedApply
20
21
  end
21
22
 
23
+ #: (Module) -> void
22
24
  def self.included(base)
23
25
  base.include ::ActionPolicy::Behaviour
24
26
  base.extend ClassMethods
25
27
  end
26
28
 
29
+ # Class-level methods for configuring authorization on operations.
27
30
  module ClassMethods
28
- # Configure the operation to use ActionPolicy for authorization
31
+ # @rbs @_authorized_via_param: Array[Symbol]?
32
+ # @rbs @_policy_method: Symbol?
33
+ # @rbs @_policy_class: Class?
34
+ # @rbs @_to_authorize_param: Symbol?
35
+ # @rbs @_action_type: Symbol?
36
+ # @rbs @_verify_authorized: bool?
37
+
38
+ # Configure the operation to use ActionPolicy for authorization.
39
+ #: (*Symbol, ?with: Class?, ?to: Symbol?, ?record: Symbol?) ?{ () -> bool } -> void
29
40
  def authorized_via(*via, with: nil, to: nil, record: nil, &auth_block)
30
- # If a block is provided, you must not provide a policy class or method
31
41
  raise ArgumentError, "You must not provide a policy class or method when using a block" if auth_block && (with || to)
32
42
 
33
43
  parameters = positional_parameters + keyword_parameters
@@ -35,10 +45,8 @@ module TypedOperation
35
45
  @_authorized_via_param = via
36
46
 
37
47
  action_type_method = :"#{action_type}?" if action_type
38
- # If an method name is provided, use it
39
48
  policy_method = to || action_type_method || raise(::TypedOperation::InvalidOperationError, "You must provide an action type or policy method name")
40
49
  @_policy_method = policy_method
41
- # If a policy class is provided, use it
42
50
  @_policy_class = if with
43
51
  with
44
52
  elsif auth_block
@@ -60,44 +68,52 @@ module TypedOperation
60
68
  @_to_authorize_param = record
61
69
  end
62
70
 
63
- # Configure action policy to use the param named in via as the context when instantiating the policy
64
- # ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param
71
+ # Configure action policy to use the param named in via as the context when instantiating the policy.
72
+ # ::ActionPolicy::Behaviour does not provide a authorize(*ids) method, so we have call once per param.
65
73
  via.each do |param|
66
74
  authorize param
67
75
  end
68
76
  end
69
77
 
78
+ #: (?Symbol?) -> Symbol?
70
79
  def action_type(type = nil)
71
80
  @_action_type = type.to_sym if type
72
81
  @_action_type
73
82
  end
74
83
 
84
+ #: () -> Symbol?
75
85
  def operation_policy_method
76
86
  @_policy_method
77
87
  end
78
88
 
89
+ #: () -> Class?
79
90
  def operation_policy_class
80
91
  @_policy_class
81
92
  end
82
93
 
94
+ #: () -> Symbol?
83
95
  def operation_record_to_authorize
84
96
  @_to_authorize_param
85
97
  end
86
98
 
99
+ #: () -> bool
87
100
  def checks_authorization?
88
101
  !(@_authorized_via_param.nil? || @_authorized_via_param.empty?)
89
102
  end
90
103
 
91
- # You can use this on an operation base class to ensure and subclasses always enable authorization
104
+ # You can use this on an operation base class to ensure subclasses always enable authorization.
105
+ #: () -> void
92
106
  def verify_authorized!
93
107
  return if verify_authorized?
94
108
  @_verify_authorized = true
95
109
  end
96
110
 
111
+ #: () -> bool
97
112
  def verify_authorized?
98
113
  @_verify_authorized
99
114
  end
100
115
 
116
+ #: (Class) -> void
101
117
  def inherited(subclass)
102
118
  super
103
119
  subclass.instance_variable_set(:@_authorized_via_param, @_authorized_via_param)
@@ -110,31 +126,35 @@ module TypedOperation
110
126
 
111
127
  private
112
128
 
113
- # Redefine it as private
129
+ #: () -> untyped
114
130
  def execute_operation
115
- if self.class.verify_authorized? && !self.class.checks_authorization?
116
- raise ::TypedOperation::MissingAuthentication, "Operation #{self.class.name} must authorize. Remember to use `.authorize_via`"
131
+ operation_class = self.class
132
+ checks_auth = operation_class.checks_authorization?
133
+ if operation_class.verify_authorized? && !checks_auth
134
+ raise ::TypedOperation::MissingAuthentication, "Operation #{operation_class.name} must authorize. Remember to use `.authorize_via`"
117
135
  end
118
- operation_check_authorized! if self.class.checks_authorization?
136
+ operation_check_authorized! if checks_auth
119
137
  super
120
138
  end
121
139
 
140
+ #: () -> void
122
141
  def operation_check_authorized!
123
- policy = self.class.operation_policy_class
124
- raise "No Action Policy policy class provided, or no #{self.class.name}::Policy found for this action" unless policy
125
- policy_method = self.class.operation_policy_method
126
- raise "No policy method provided or action_type not set for #{self.class.name}" unless policy_method
142
+ operation_class = self.class
143
+ policy = operation_class.operation_policy_class
144
+ raise "No Action Policy policy class provided, or no #{operation_class.name}::Policy found for this action" unless policy
145
+ raise "No policy method provided or action_type not set for #{operation_class.name}" unless operation_class.operation_policy_method
127
146
  # Record to authorize, if nil then action policy tries to work it out implicitly
128
- record_to_authorize = send(self.class.operation_record_to_authorize) if self.class.operation_record_to_authorize
147
+ record_to_authorize = send(operation_class.operation_record_to_authorize) if operation_class.operation_record_to_authorize
129
148
 
130
- authorize! record_to_authorize, to: policy_method, with: policy
131
- rescue ::ActionPolicy::Unauthorized => e
132
- on_authorization_failure(e)
133
- raise e
149
+ authorize! record_to_authorize, to: operation_class.operation_policy_method, with: policy
150
+ rescue ::ActionPolicy::Unauthorized => error
151
+ on_authorization_failure(error)
152
+ raise error
134
153
  end
135
154
 
136
- # A hook for subclasses to override to do something on an authorization failure
137
- def on_authorization_failure(authorization_error)
155
+ # A hook for subclasses to override to do something on an authorization failure.
156
+ #: (ActionPolicy::Unauthorized) -> void
157
+ def on_authorization_failure(_authorization_error)
138
158
  # noop
139
159
  end
140
160
  end
@@ -1,13 +1,16 @@
1
+ # rbs_inline: enabled
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module TypedOperation
5
+ # Mutable base class for operations, built on Literal::Struct.
6
+ # Use this when operation state can be modified after initialization.
4
7
  class Base < Literal::Struct
5
8
  extend Operations::Introspection
6
9
  extend Operations::Parameters
7
10
  extend Operations::PartialApplication
11
+ extend Operations::Composition
8
12
 
9
13
  include Operations::Lifecycle
10
- include Operations::Callable
11
14
  include Operations::Executable
12
15
  end
13
16
  end
@@ -0,0 +1,30 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Utility module for resolving callable objects (operations, procs, chains)
6
+ # and determining how to invoke them (kwargs vs positional).
7
+ module CallableResolver
8
+ #: (_Callable) -> untyped
9
+ def extract_operation_class(operation)
10
+ case operation
11
+ when Class then operation
12
+ when TypedOperation::PartiallyApplied then operation.operation_class
13
+ when TypedOperation::ChainedOperation then extract_operation_class(operation.instance_variable_get(:@left))
14
+ when Proc then nil
15
+ else operation.class
16
+ end
17
+ end
18
+
19
+ #: (_Callable) -> bool
20
+ def uses_kwargs?(operation)
21
+ op_class = extract_operation_class(operation)
22
+ return false unless op_class.respond_to?(:positional_parameters)
23
+
24
+ positional = op_class.positional_parameters
25
+ keywords = op_class.keyword_parameters
26
+
27
+ keywords.any? && positional.empty?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Base class for operation chains.
6
+ #
7
+ # Values flow through chains as-is - no implicit splatting.
8
+ # If you need to convert a value to kwargs for the next operation,
9
+ # use .then_passes { |v| NextOp.call(**v) } or .transform to reshape first.
10
+ class ChainedOperation
11
+ include Operations::Composition
12
+
13
+ # @rbs @left: _Callable
14
+ # @rbs @right: _Callable
15
+
16
+ #: (_Callable, _Callable) -> void
17
+ def initialize(left, right)
18
+ @left = left
19
+ @right = right
20
+ end
21
+
22
+ #: (*untyped, **untyped) -> _Result[untyped, untyped]
23
+ def call(...)
24
+ raise NotImplementedError, "Subclasses must implement #call"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Fallback chain created by .or_else
6
+ # Calls the fallback only when the left operation fails.
7
+ # Passes through success unchanged.
8
+ #
9
+ # For blocks: passes the failure value.
10
+ # For operations: passes the original call arguments (so fallback can retry).
11
+ class FallbackChain < ChainedOperation
12
+ #: (*untyped, **untyped) -> _Result[untyped, untyped]
13
+ def call(*args, **kwargs)
14
+ result = @left.call(*args, **kwargs)
15
+ return result if result.success?
16
+
17
+ if Instrumentation.tracing?
18
+ Instrumentation.set_chain_context(
19
+ pass_mode: :fallback,
20
+ extracted_params: nil,
21
+ fallback_used: true
22
+ )
23
+ end
24
+
25
+ if @right.is_a?(Proc)
26
+ @right.call(result.failure)
27
+ else
28
+ @right.call(*args, **kwargs)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Functor map chain created by .transform
6
+ # Block receives context, return value replaces context (wrapped in Success).
7
+ # Short-circuits on failure.
8
+ class MapChain < 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
+ # Transform replaces context entirely (no merge)
20
+ transformed = @right.call(context)
21
+ Success(transformed)
22
+ end
23
+
24
+ private
25
+
26
+ #: (untyped) -> Hash[Symbol, untyped]
27
+ def to_context(value)
28
+ if value.respond_to?(:to_hash)
29
+ value.to_hash
30
+ elsif value.respond_to?(:to_h)
31
+ value.to_h
32
+ else
33
+ {_value: value}
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Sequential composition chain used by .then_passes
6
+ # Passes the success value (context) to the right operation/block.
7
+ # The result is merged back into context.
8
+ # Short-circuits on failure.
9
+ class SequenceChain < ChainedOperation
10
+ include Result::Mixin
11
+
12
+ #: (*untyped, **untyped) -> _Result[untyped, untyped]
13
+ def call(...)
14
+ result = @left.call(...)
15
+ return result if result.failure?
16
+
17
+ left_value = result.value!
18
+ context = to_context(left_value)
19
+
20
+ right_result = @right.call(context)
21
+ return right_result if right_result.failure?
22
+
23
+ # Merge right's output into context
24
+ right_value = right_result.value!
25
+ merged = merge_into_context(context, right_value)
26
+
27
+ Success(merged)
28
+ end
29
+
30
+ private
31
+
32
+ #: (untyped) -> Hash[Symbol, untyped]
33
+ def to_context(value)
34
+ if value.respond_to?(:to_hash)
35
+ value.to_hash
36
+ elsif value.respond_to?(:to_h)
37
+ value.to_h
38
+ else
39
+ {_value: value}
40
+ end
41
+ end
42
+
43
+ #: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
44
+ def merge_into_context(context, value)
45
+ if value.respond_to?(:to_hash)
46
+ context.merge(value.to_hash)
47
+ elsif value.respond_to?(:to_h)
48
+ context.merge(value.to_h)
49
+ else
50
+ context.merge(_value: value)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,161 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module TypedOperation
5
+ # Smart chain created by .then
6
+ # Accumulates context across chain steps and extracts required params for each operation.
7
+ class SmartChain < ChainedOperation
8
+ include Result::Mixin
9
+ include CallableResolver
10
+
11
+ # @rbs @extra_kwargs: Hash[Symbol, untyped]
12
+ # @rbs @uses_kwargs: bool
13
+ # @rbs @required_params: Array[Symbol]
14
+ # @rbs @prefilled_params: Hash[Symbol, untyped]
15
+
16
+ #: (_Callable, _Callable, ?Hash[Symbol, untyped]) -> void
17
+ def initialize(left, right, extra_kwargs = {})
18
+ super(left, right)
19
+ @extra_kwargs = extra_kwargs
20
+ @uses_kwargs = uses_kwargs?(right)
21
+ @required_params = extract_required_params(right)
22
+ @prefilled_params = extract_prefilled_params(right)
23
+ end
24
+
25
+ #: (*untyped, **untyped) -> _Result[untyped, untyped]
26
+ def call(*args, **kwargs)
27
+ # Execute left side
28
+ result = @left.call(*args, **kwargs)
29
+ return result if result.failure?
30
+
31
+ # Build context from left's result
32
+ left_value = result.value!
33
+ context = to_context(left_value)
34
+
35
+ # Call right side with appropriate params
36
+ right_result = call_right(context)
37
+ return right_result if right_result.failure?
38
+
39
+ # Merge right's output into context
40
+ right_value = right_result.value!
41
+ merged = merge_into_context(context, right_value)
42
+
43
+ Success(merged)
44
+ end
45
+
46
+ private
47
+
48
+ #: (untyped) -> Hash[Symbol, untyped]
49
+ def to_context(value)
50
+ if value.respond_to?(:to_hash)
51
+ value.to_hash
52
+ elsif value.respond_to?(:to_h)
53
+ value.to_h
54
+ else
55
+ {_value: value}
56
+ end
57
+ end
58
+
59
+ #: (Hash[Symbol, untyped], untyped) -> Hash[Symbol, untyped]
60
+ def merge_into_context(context, value)
61
+ if value.respond_to?(:to_hash)
62
+ context.merge(value.to_hash)
63
+ elsif value.respond_to?(:to_h)
64
+ context.merge(value.to_h)
65
+ else
66
+ context.merge(_value: value)
67
+ end
68
+ end
69
+
70
+ #: (Hash[Symbol, untyped]) -> _Result[untyped, untyped]
71
+ def call_right(context)
72
+ if @uses_kwargs
73
+ # Extract only the params the operation needs from context
74
+ extracted = extract_from_context(context, @required_params)
75
+
76
+ # Merge: extracted from context < prefilled from .with < extra kwargs from .then
77
+ call_kwargs = extracted.merge(@prefilled_params).merge(@extra_kwargs)
78
+
79
+ # Check for missing required params
80
+ validate_required_params!(call_kwargs)
81
+
82
+ # Set trace context if tracing
83
+ if Instrumentation.tracing?
84
+ Instrumentation.set_chain_context(
85
+ pass_mode: :kwargs_splat,
86
+ extracted_params: extracted.keys
87
+ )
88
+ end
89
+
90
+ @right.call(**call_kwargs)
91
+ else
92
+ # Positional param operation - pass full context
93
+ if Instrumentation.tracing?
94
+ Instrumentation.set_chain_context(
95
+ pass_mode: :single_value,
96
+ extracted_params: nil
97
+ )
98
+ end
99
+
100
+ @right.call(context)
101
+ end
102
+ end
103
+
104
+ #: (Hash[Symbol, untyped], Array[Symbol]) -> Hash[Symbol, untyped]
105
+ def extract_from_context(context, params)
106
+ result = {} #: Hash[Symbol, untyped]
107
+ params.each do |key|
108
+ result[key] = context[key] if context.key?(key)
109
+ end
110
+ result
111
+ end
112
+
113
+ #: (Hash[Symbol, untyped]) -> void
114
+ def validate_required_params!(kwargs)
115
+ op_class = extract_operation_class(@right)
116
+ return unless op_class.respond_to?(:required_keyword_parameters)
117
+
118
+ required = op_class.required_keyword_parameters
119
+ missing = required - kwargs.keys
120
+
121
+ if missing.any?
122
+ raise ArgumentError,
123
+ "Missing required parameters for #{op_class.name}: #{missing.join(", ")}. " \
124
+ "Available in context or provide via .with or .then(op, key: value)"
125
+ end
126
+ end
127
+
128
+ #: (_Callable) -> bool
129
+ def uses_kwargs?(operation)
130
+ op_class = extract_operation_class(operation)
131
+ return false unless op_class.respond_to?(:positional_parameters)
132
+
133
+ positional = op_class.positional_parameters
134
+ keywords = op_class.keyword_parameters
135
+
136
+ if positional.any? && keywords.any?
137
+ raise ArgumentError,
138
+ "Cannot chain to operation with both positional and keyword params. Use .transform to adapt."
139
+ end
140
+
141
+ super
142
+ end
143
+
144
+ #: (_Callable) -> Array[Symbol]
145
+ def extract_required_params(operation)
146
+ op_class = extract_operation_class(operation)
147
+ return [] unless op_class.respond_to?(:keyword_parameters)
148
+ op_class.keyword_parameters
149
+ end
150
+
151
+ #: (_Callable) -> Hash[Symbol, untyped]
152
+ def extract_prefilled_params(operation)
153
+ case operation
154
+ when TypedOperation::PartiallyApplied
155
+ operation.keyword_args
156
+ else
157
+ {}
158
+ end
159
+ end
160
+ end
161
+ end
@@ -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