typed_operation 1.0.0.pre3 → 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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -624
  3. data/lib/generators/typed_operation/install/USAGE +1 -0
  4. data/lib/generators/typed_operation/install/install_generator.rb +8 -0
  5. data/lib/generators/typed_operation/install/templates/application_operation.rb +24 -2
  6. data/lib/generators/typed_operation_generator.rb +8 -4
  7. data/lib/typed_operation/action_policy_auth.rb +161 -0
  8. data/lib/typed_operation/base.rb +4 -13
  9. data/lib/typed_operation/callable_resolver.rb +30 -0
  10. data/lib/typed_operation/chains/chained_operation.rb +27 -0
  11. data/lib/typed_operation/chains/fallback_chain.rb +32 -0
  12. data/lib/typed_operation/chains/map_chain.rb +37 -0
  13. data/lib/typed_operation/chains/sequence_chain.rb +54 -0
  14. data/lib/typed_operation/chains/smart_chain.rb +161 -0
  15. data/lib/typed_operation/chains/splat_chain.rb +53 -0
  16. data/lib/typed_operation/configuration.rb +52 -0
  17. data/lib/typed_operation/context.rb +193 -0
  18. data/lib/typed_operation/curried.rb +14 -1
  19. data/lib/typed_operation/explainable.rb +14 -0
  20. data/lib/typed_operation/immutable_base.rb +4 -2
  21. data/lib/typed_operation/instrumentation/trace.rb +71 -0
  22. data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
  23. data/lib/typed_operation/instrumentation.rb +214 -0
  24. data/lib/typed_operation/operations/composition.rb +41 -0
  25. data/lib/typed_operation/operations/executable.rb +27 -1
  26. data/lib/typed_operation/operations/introspection.rb +14 -8
  27. data/lib/typed_operation/operations/lifecycle.rb +5 -2
  28. data/lib/typed_operation/operations/parameters.rb +21 -6
  29. data/lib/typed_operation/operations/partial_application.rb +4 -0
  30. data/lib/typed_operation/operations/property_builder.rb +105 -0
  31. data/lib/typed_operation/partially_applied.rb +33 -10
  32. data/lib/typed_operation/pipeline/builder.rb +88 -0
  33. data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
  34. data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
  35. data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
  36. data/lib/typed_operation/pipeline.rb +176 -0
  37. data/lib/typed_operation/prepared.rb +13 -0
  38. data/lib/typed_operation/railtie.rb +4 -0
  39. data/lib/typed_operation/result/adapters/built_in.rb +28 -0
  40. data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
  41. data/lib/typed_operation/result/failure.rb +78 -0
  42. data/lib/typed_operation/result/mixin.rb +24 -0
  43. data/lib/typed_operation/result/success.rb +75 -0
  44. data/lib/typed_operation/result.rb +39 -0
  45. data/lib/typed_operation/version.rb +5 -1
  46. data/lib/typed_operation.rb +18 -6
  47. metadata +58 -18
  48. data/Rakefile +0 -17
  49. data/lib/tasks/typed_operation_tasks.rake +0 -4
  50. data/lib/typed_operation/operations/attribute_builder.rb +0 -81
  51. data/lib/typed_operation/operations/callable.rb +0 -23
  52. data/lib/typed_operation/operations/deconstruct.rb +0 -16
@@ -12,6 +12,7 @@ rails generate typed_operation:install
12
12
  Options:
13
13
  --------
14
14
  --dry_monads: if specified the ApplicationOperation will include dry-monads Result and Do notation.
15
+ --action_policy: if specified the ApplicationOperation will include action_policy authorization integration.
15
16
 
16
17
  Example:
17
18
  --------
@@ -3,9 +3,13 @@
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
12
+ class_option :action_policy, type: :boolean, default: false
9
13
 
10
14
  source_root File.expand_path("templates", __dir__)
11
15
 
@@ -18,6 +22,10 @@ module TypedOperation
18
22
  def include_dry_monads?
19
23
  options[:dry_monads]
20
24
  end
25
+
26
+ def include_action_policy?
27
+ options[:action_policy]
28
+ end
21
29
  end
22
30
  end
23
31
  end
@@ -1,14 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ApplicationOperation < ::TypedOperation::Base
4
+ <% if include_action_policy? -%>
5
+ include TypedOperation::ActionPolicyAuth
6
+
7
+ <% end -%>
4
8
  <% if include_dry_monads? -%>
5
- include Dry::Monads[:result, :do]
9
+ include Dry::Monads[:result]
10
+ include Dry::Monads::Do.for(:perform)
6
11
 
12
+ # Helper to execute then unwrap a successful result or raise an exception
7
13
  def call!
8
14
  call.value!
9
15
  end
10
16
 
11
17
  <% end -%>
12
18
  # Other common parameters & methods for Operations of this application...
13
- # ...
19
+ # Some examples:
20
+ #
21
+ # def self.operation_key
22
+ # name.underscore.to_sym
23
+ # end
24
+ #
25
+ # def operation_key
26
+ # self.class.operation_key
27
+ # end
28
+ #
29
+ # # Translation and localization
30
+ #
31
+ # def translate(key, **)
32
+ # key = "operations.#{operation_key}.#{key}" if key.start_with?(".")
33
+ # I18n.t(key, **)
34
+ # end
35
+ # alias_method :t, :translate
14
36
  end
@@ -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
 
@@ -0,0 +1,161 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "action_policy"
5
+
6
+ # An optional module to include in your operation to enable authorization via ActionPolicies.
7
+ module TypedOperation
8
+ class MissingAuthentication < StandardError; end
9
+
10
+ module ActionPolicyAuth
11
+ # Base class for any action policy classes used by operations.
12
+ class OperationPolicy
13
+ include ActionPolicy::Policy::Core
14
+ include ActionPolicy::Policy::Authorization
15
+ include ActionPolicy::Policy::PreCheck
16
+ include ActionPolicy::Policy::Reasons
17
+ include ActionPolicy::Policy::Aliases
18
+ include ActionPolicy::Policy::Scoping
19
+ include ActionPolicy::Policy::Cache
20
+ include ActionPolicy::Policy::CachedApply
21
+ end
22
+
23
+ #: (Module) -> void
24
+ def self.included(base)
25
+ base.include ::ActionPolicy::Behaviour
26
+ base.extend ClassMethods
27
+ end
28
+
29
+ # Class-level methods for configuring authorization on operations.
30
+ module ClassMethods
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
40
+ def authorized_via(*via, with: nil, to: nil, record: nil, &auth_block)
41
+ raise ArgumentError, "You must not provide a policy class or method when using a block" if auth_block && (with || to)
42
+
43
+ parameters = positional_parameters + keyword_parameters
44
+ raise ArgumentError, "authorize_via must be called with a valid param name" unless via.all? { |param| parameters.include?(param) }
45
+ @_authorized_via_param = via
46
+
47
+ action_type_method = :"#{action_type}?" if action_type
48
+ policy_method = to || action_type_method || raise(::TypedOperation::InvalidOperationError, "You must provide an action type or policy method name")
49
+ @_policy_method = policy_method
50
+ @_policy_class = if with
51
+ with
52
+ elsif auth_block
53
+ policy_class = Class.new(OperationPolicy) do
54
+ authorize(*via)
55
+
56
+ define_method(policy_method, &auth_block)
57
+ end
58
+ const_set(:Policy, policy_class)
59
+ policy_class
60
+ else
61
+ raise ::TypedOperation::InvalidOperationError, "You must provide either a policy class or a block"
62
+ end
63
+
64
+ if record
65
+ unless parameters.include?(record) || method_defined?(record) || private_method_defined?(record)
66
+ raise ArgumentError, "to_authorize must be called with a valid param or method name"
67
+ end
68
+ @_to_authorize_param = record
69
+ end
70
+
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.
73
+ via.each do |param|
74
+ authorize param
75
+ end
76
+ end
77
+
78
+ #: (?Symbol?) -> Symbol?
79
+ def action_type(type = nil)
80
+ @_action_type = type.to_sym if type
81
+ @_action_type
82
+ end
83
+
84
+ #: () -> Symbol?
85
+ def operation_policy_method
86
+ @_policy_method
87
+ end
88
+
89
+ #: () -> Class?
90
+ def operation_policy_class
91
+ @_policy_class
92
+ end
93
+
94
+ #: () -> Symbol?
95
+ def operation_record_to_authorize
96
+ @_to_authorize_param
97
+ end
98
+
99
+ #: () -> bool
100
+ def checks_authorization?
101
+ !(@_authorized_via_param.nil? || @_authorized_via_param.empty?)
102
+ end
103
+
104
+ # You can use this on an operation base class to ensure subclasses always enable authorization.
105
+ #: () -> void
106
+ def verify_authorized!
107
+ return if verify_authorized?
108
+ @_verify_authorized = true
109
+ end
110
+
111
+ #: () -> bool
112
+ def verify_authorized?
113
+ @_verify_authorized
114
+ end
115
+
116
+ #: (Class) -> void
117
+ def inherited(subclass)
118
+ super
119
+ subclass.instance_variable_set(:@_authorized_via_param, @_authorized_via_param)
120
+ subclass.instance_variable_set(:@_verify_authorized, @_verify_authorized)
121
+ subclass.instance_variable_set(:@_policy_class, @_policy_class)
122
+ subclass.instance_variable_set(:@_policy_method, @_policy_method)
123
+ subclass.instance_variable_set(:@_action_type, @_action_type)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ #: () -> untyped
130
+ def execute_operation
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`"
135
+ end
136
+ operation_check_authorized! if checks_auth
137
+ super
138
+ end
139
+
140
+ #: () -> void
141
+ def operation_check_authorized!
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
146
+ # Record to authorize, if nil then action policy tries to work it out implicitly
147
+ record_to_authorize = send(operation_class.operation_record_to_authorize) if operation_class.operation_record_to_authorize
148
+
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
153
+ end
154
+
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)
158
+ # noop
159
+ end
160
+ end
161
+ end
@@ -1,25 +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
- include Operations::Deconstruct
12
14
  include Operations::Executable
13
-
14
- class << self
15
- def attribute(name, type, special = nil, reader: :public, writer: :public, positional: false, default: nil)
16
- super(name, type, special, reader:, writer: false, positional:, default:)
17
- end
18
- end
19
-
20
- def with(...)
21
- # copy to new operation with new attrs
22
- self.class.new(**attributes.merge(...))
23
- end
24
15
  end
25
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