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
|
@@ -14,7 +14,7 @@ module <%= namespace_name %>
|
|
|
14
14
|
# Prepare...
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def perform
|
|
18
18
|
# Perform...
|
|
19
19
|
"Hello World!"
|
|
20
20
|
end
|
|
@@ -33,7 +33,7 @@ class <%= name %> < ::ApplicationOperation
|
|
|
33
33
|
# Prepare...
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def
|
|
36
|
+
def perform
|
|
37
37
|
# Perform...
|
|
38
38
|
"Hello World!"
|
|
39
39
|
end
|
|
@@ -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
|
|
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(
|
|
13
|
-
File.join(
|
|
16
|
+
File.join(source_root, "operation.rb"),
|
|
17
|
+
File.join(operation_path, "#{file_name}.rb")
|
|
14
18
|
)
|
|
15
19
|
template(
|
|
16
|
-
File.join(
|
|
17
|
-
File.join("test/",
|
|
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
|
data/lib/typed_operation/base.rb
CHANGED
|
@@ -1,24 +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
|
-
include Operations::Callable
|
|
10
13
|
include Operations::Lifecycle
|
|
11
|
-
include Operations::
|
|
12
|
-
|
|
13
|
-
class << self
|
|
14
|
-
def attribute(name, type, special = nil, reader: :public, writer: :public, positional: false, default: nil)
|
|
15
|
-
super(name, type, special, reader:, writer: false, positional:, default:)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def with(...)
|
|
20
|
-
# copy to new operation with new attrs
|
|
21
|
-
self.class.new(**attributes.merge(...))
|
|
22
|
-
end
|
|
14
|
+
include Operations::Executable
|
|
23
15
|
end
|
|
24
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
|