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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -574
  3. data/lib/generators/templates/operation.rb +2 -2
  4. data/lib/generators/typed_operation/install/USAGE +1 -0
  5. data/lib/generators/typed_operation/install/install_generator.rb +8 -0
  6. data/lib/generators/typed_operation/install/templates/application_operation.rb +24 -2
  7. data/lib/generators/typed_operation_generator.rb +8 -4
  8. data/lib/typed_operation/action_policy_auth.rb +161 -0
  9. data/lib/typed_operation/base.rb +5 -13
  10. data/lib/typed_operation/callable_resolver.rb +30 -0
  11. data/lib/typed_operation/chains/chained_operation.rb +27 -0
  12. data/lib/typed_operation/chains/fallback_chain.rb +32 -0
  13. data/lib/typed_operation/chains/map_chain.rb +37 -0
  14. data/lib/typed_operation/chains/sequence_chain.rb +54 -0
  15. data/lib/typed_operation/chains/smart_chain.rb +161 -0
  16. data/lib/typed_operation/chains/splat_chain.rb +53 -0
  17. data/lib/typed_operation/configuration.rb +52 -0
  18. data/lib/typed_operation/context.rb +193 -0
  19. data/lib/typed_operation/curried.rb +14 -1
  20. data/lib/typed_operation/explainable.rb +14 -0
  21. data/lib/typed_operation/immutable_base.rb +5 -2
  22. data/lib/typed_operation/instrumentation/trace.rb +71 -0
  23. data/lib/typed_operation/instrumentation/tree_formatter.rb +141 -0
  24. data/lib/typed_operation/instrumentation.rb +214 -0
  25. data/lib/typed_operation/operations/composition.rb +41 -0
  26. data/lib/typed_operation/operations/executable.rb +55 -0
  27. data/lib/typed_operation/operations/introspection.rb +14 -8
  28. data/lib/typed_operation/operations/lifecycle.rb +5 -1
  29. data/lib/typed_operation/operations/parameters.rb +21 -6
  30. data/lib/typed_operation/operations/partial_application.rb +4 -0
  31. data/lib/typed_operation/operations/property_builder.rb +105 -0
  32. data/lib/typed_operation/partially_applied.rb +33 -10
  33. data/lib/typed_operation/pipeline/builder.rb +88 -0
  34. data/lib/typed_operation/pipeline/chainable_wrapper.rb +23 -0
  35. data/lib/typed_operation/pipeline/empty_pipeline_chain.rb +25 -0
  36. data/lib/typed_operation/pipeline/step_wrapper.rb +94 -0
  37. data/lib/typed_operation/pipeline.rb +176 -0
  38. data/lib/typed_operation/prepared.rb +13 -0
  39. data/lib/typed_operation/railtie.rb +4 -0
  40. data/lib/typed_operation/result/adapters/built_in.rb +28 -0
  41. data/lib/typed_operation/result/adapters/dry_monads.rb +36 -0
  42. data/lib/typed_operation/result/failure.rb +78 -0
  43. data/lib/typed_operation/result/mixin.rb +24 -0
  44. data/lib/typed_operation/result/success.rb +75 -0
  45. data/lib/typed_operation/result.rb +39 -0
  46. data/lib/typed_operation/version.rb +5 -1
  47. data/lib/typed_operation.rb +19 -6
  48. metadata +59 -18
  49. data/Rakefile +0 -17
  50. data/lib/tasks/typed_operation_tasks.rake +0 -4
  51. data/lib/typed_operation/operations/attribute_builder.rb +0 -75
  52. data/lib/typed_operation/operations/callable.rb +0 -27
  53. 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
- @partial_operation.with(next_keyword_parameter => arg)
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::Deconstruct
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