axn 0.1.0.pre.alpha.3 → 0.1.0.pre.alpha.4

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/CHANGELOG.md +15 -1
  4. data/Rakefile +102 -2
  5. data/docs/.vitepress/config.mjs +12 -8
  6. data/docs/advanced/conventions.md +1 -1
  7. data/docs/advanced/mountable.md +4 -90
  8. data/docs/advanced/profiling.md +26 -30
  9. data/docs/advanced/rough.md +27 -8
  10. data/docs/intro/overview.md +1 -1
  11. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  12. data/docs/recipes/memoization.md +102 -17
  13. data/docs/reference/async.md +269 -0
  14. data/docs/reference/class.md +113 -50
  15. data/docs/reference/configuration.md +226 -75
  16. data/docs/reference/form-object.md +252 -0
  17. data/docs/strategies/client.md +212 -0
  18. data/docs/strategies/form.md +235 -0
  19. data/docs/usage/setup.md +2 -2
  20. data/docs/usage/writing.md +99 -1
  21. data/lib/axn/async/adapters/active_job.rb +19 -10
  22. data/lib/axn/async/adapters/disabled.rb +15 -0
  23. data/lib/axn/async/adapters/sidekiq.rb +25 -32
  24. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  25. data/lib/axn/async/batch_enqueue.rb +99 -0
  26. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  27. data/lib/axn/async.rb +121 -4
  28. data/lib/axn/configuration.rb +53 -13
  29. data/lib/axn/context.rb +1 -0
  30. data/lib/axn/core/automatic_logging.rb +47 -51
  31. data/lib/axn/core/context/facade_inspector.rb +1 -1
  32. data/lib/axn/core/contract.rb +73 -30
  33. data/lib/axn/core/contract_for_subfields.rb +1 -1
  34. data/lib/axn/core/contract_validation.rb +14 -9
  35. data/lib/axn/core/contract_validation_for_subfields.rb +14 -7
  36. data/lib/axn/core/default_call.rb +63 -0
  37. data/lib/axn/core/flow/exception_execution.rb +5 -0
  38. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +19 -7
  39. data/lib/axn/core/flow/handlers/invoker.rb +4 -30
  40. data/lib/axn/core/flow/handlers/matcher.rb +4 -14
  41. data/lib/axn/core/flow/messages.rb +1 -1
  42. data/lib/axn/core/hooks.rb +1 -0
  43. data/lib/axn/core/logging.rb +16 -5
  44. data/lib/axn/core/memoization.rb +53 -0
  45. data/lib/axn/core/tracing.rb +77 -4
  46. data/lib/axn/core/validation/validators/type_validator.rb +1 -1
  47. data/lib/axn/core.rb +31 -46
  48. data/lib/axn/extras/strategies/client.rb +150 -0
  49. data/lib/axn/extras/strategies/vernier.rb +121 -0
  50. data/lib/axn/extras.rb +4 -0
  51. data/lib/axn/factory.rb +22 -2
  52. data/lib/axn/form_object.rb +90 -0
  53. data/lib/axn/internal/logging.rb +5 -1
  54. data/lib/axn/mountable/helpers/class_builder.rb +41 -10
  55. data/lib/axn/mountable/helpers/namespace_manager.rb +6 -34
  56. data/lib/axn/mountable/inherit_profiles.rb +2 -2
  57. data/lib/axn/mountable/mounting_strategies/_base.rb +10 -6
  58. data/lib/axn/mountable/mounting_strategies/method.rb +2 -2
  59. data/lib/axn/mountable.rb +41 -7
  60. data/lib/axn/rails/generators/axn_generator.rb +19 -1
  61. data/lib/axn/rails/generators/templates/action.rb.erb +1 -1
  62. data/lib/axn/result.rb +2 -2
  63. data/lib/axn/strategies/form.rb +98 -0
  64. data/lib/axn/strategies/transaction.rb +7 -0
  65. data/lib/axn/util/callable.rb +120 -0
  66. data/lib/axn/util/contract_error_handling.rb +32 -0
  67. data/lib/axn/util/execution_context.rb +34 -0
  68. data/lib/axn/util/global_id_serialization.rb +52 -0
  69. data/lib/axn/util/logging.rb +87 -0
  70. data/lib/axn/version.rb +1 -1
  71. data/lib/axn.rb +9 -0
  72. metadata +22 -4
  73. data/lib/axn/core/profiling.rb +0 -124
  74. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +0 -55
@@ -9,13 +9,18 @@ module Axn
9
9
  include InstanceMethods
10
10
 
11
11
  # Single class_attribute - nil means disabled, any level means enabled
12
- class_attribute :auto_log_level, default: Axn.config.log_level
12
+ class_attribute :log_calls_level, default: Axn.config.log_level
13
+ class_attribute :log_errors_level, default: nil
13
14
  end
14
15
  end
15
16
 
16
17
  module ClassMethods
17
- def auto_log(level)
18
- self.auto_log_level = level.presence
18
+ def log_calls(level)
19
+ self.log_calls_level = level.presence
20
+ end
21
+
22
+ def log_errors(level)
23
+ self.log_errors_level = level.presence
19
24
  end
20
25
  end
21
26
 
@@ -23,69 +28,60 @@ module Axn
23
28
  private
24
29
 
25
30
  def _with_logging
26
- _log_before if self.class.auto_log_level
31
+ _log_before if self.class.log_calls_level
27
32
  yield
28
33
  ensure
29
- _log_after if self.class.auto_log_level
34
+ _log_after if self.class.log_calls_level || self.class.log_errors_level
30
35
  end
31
36
 
32
37
  def _log_before
33
- level = self.class.auto_log_level
34
- return unless level
35
-
36
- self.class.public_send(
37
- level,
38
- [
39
- "About to execute",
40
- _log_context(:inbound),
41
- ].compact.join(" with: "),
42
- before: Axn.config.env.production? ? nil : "\n------\n",
38
+ Axn::Util::Logging.log_at_level(
39
+ self.class,
40
+ level: self.class.log_calls_level,
41
+ message_parts: ["About to execute"],
42
+ join_string: " with: ",
43
+ before: _top_level_separator,
44
+ error_context: "logging before hook",
45
+ context_direction: :inbound,
46
+ context_instance: self,
43
47
  )
44
- rescue StandardError => e
45
- Axn::Internal::Logging.piping_error("logging before hook", action: self, exception: e)
46
48
  end
47
49
 
48
50
  def _log_after
49
- level = self.class.auto_log_level
50
- return unless level
51
-
52
- self.class.public_send(
53
- level,
54
- [
55
- "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
56
- _log_context(:outbound),
57
- ].compact.join(". Set: "),
58
- after: Axn.config.env.production? ? nil : "\n------\n",
59
- )
60
- rescue StandardError => e
61
- Axn::Internal::Logging.piping_error("logging after hook", action: self, exception: e)
62
- end
51
+ # Check log_calls_level first (logs all outcomes)
52
+ if self.class.log_calls_level
53
+ _log_after_at_level(self.class.log_calls_level)
54
+ return
55
+ end
63
56
 
64
- def _log_context(direction)
65
- data = context_for_logging(direction)
66
- return unless data.present?
57
+ # Check log_errors_level (only logs when result.ok? is false)
58
+ return unless self.class.log_errors_level && !result.ok?
67
59
 
68
- max_length = 150
69
- suffix = "…<truncated>…"
60
+ _log_after_at_level(self.class.log_errors_level)
61
+ end
70
62
 
71
- _log_object(data).tap do |str|
72
- return str[0, max_length - suffix.length] + suffix if str.length > max_length
73
- end
63
+ def _log_after_at_level(level)
64
+ Axn::Util::Logging.log_at_level(
65
+ self.class,
66
+ level:,
67
+ message_parts: [
68
+ "Execution completed (with outcome: #{result.outcome}) in #{result.elapsed_time} milliseconds",
69
+ ],
70
+ join_string: ". Set: ",
71
+ after: _top_level_separator,
72
+ error_context: "logging after hook",
73
+ context_direction: :outbound,
74
+ context_instance: self,
75
+ )
74
76
  end
75
77
 
76
- def _log_object(data)
77
- case data
78
- when Hash
79
- # NOTE: slightly more manual in order to avoid quotes around ActiveRecord objects' <Class#id> formatting
80
- "{#{data.map { |k, v| "#{k}: #{_log_object(v)}" }.join(", ")}}"
81
- when Array
82
- data.map { |v| _log_object(v) }
83
- else
84
- return data.to_unsafe_h if defined?(ActionController::Parameters) && data.is_a?(ActionController::Parameters)
85
- return "<#{data.class.name}##{data.to_param.presence || "unpersisted"}>" if defined?(ActiveRecord::Base) && data.is_a?(ActiveRecord::Base)
78
+ def _top_level_separator
79
+ return if Axn.config.env.production?
80
+ return if Axn::Util::ExecutionContext.background?
81
+ return if Axn::Util::ExecutionContext.console?
82
+ return if NestingTracking._current_axn_stack.size > 1
86
83
 
87
- data.inspect
88
- end
84
+ "\n------\n"
89
85
  end
90
86
  end
91
87
  end
@@ -65,7 +65,7 @@ module Axn
65
65
  inspection_filter.filter_param(field, inspected_value)
66
66
  end
67
67
 
68
- def inspection_filter = action.send(:inspection_filter)
68
+ def inspection_filter = action.class.inspection_filter
69
69
 
70
70
  def sensitive_subfields?(field)
71
71
  action.subfield_configs.any? { |config| config.on == field && config.sensitive }
@@ -41,7 +41,7 @@ module Axn
41
41
 
42
42
  _parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess:, sensitive:, **validations).tap do |configs|
43
43
  duplicated = internal_field_configs.map(&:field) & configs.map(&:field)
44
- raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
44
+ raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
45
45
 
46
46
  # NOTE: avoid <<, which would update value for parents and children
47
47
  self.internal_field_configs += configs
@@ -63,13 +63,37 @@ module Axn
63
63
 
64
64
  _parse_field_configs(*fields, allow_blank:, allow_nil:, optional:, default:, preprocess: nil, sensitive:, **validations).tap do |configs|
65
65
  duplicated = external_field_configs.map(&:field) & configs.map(&:field)
66
- raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
66
+ raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
67
67
 
68
68
  # NOTE: avoid <<, which would update value for parents and children
69
69
  self.external_field_configs += configs
70
70
  end
71
71
  end
72
72
 
73
+ def inspection_filter
74
+ @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
75
+ end
76
+
77
+ def sensitive_fields
78
+ (internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
79
+ end
80
+
81
+ def _declared_fields(direction)
82
+ raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
83
+
84
+ configs = case direction
85
+ when :inbound then internal_field_configs
86
+ when :outbound then external_field_configs
87
+ else (internal_field_configs + external_field_configs)
88
+ end
89
+
90
+ configs.map(&:field)
91
+ end
92
+
93
+ def context_for_logging(data:, direction: nil)
94
+ inspection_filter.filter(data.slice(*_declared_fields(direction)))
95
+ end
96
+
73
97
  private
74
98
 
75
99
  RESERVED_FIELD_NAMES_FOR_EXPECTATIONS = %w[
@@ -169,54 +193,73 @@ module Axn
169
193
  end
170
194
  end
171
195
 
196
+ # Set additional context to be included in exception logging
197
+ # This context is only used when exceptions occur, not in normal pre/post logging
198
+ def set_logging_context(**kwargs)
199
+ @__additional_logging_context ||= {}
200
+ @__additional_logging_context.merge!(kwargs)
201
+ end
202
+
203
+ # Clear any previously set additional logging context
204
+ def clear_logging_context
205
+ @__additional_logging_context = nil
206
+ end
207
+
172
208
  def context_for_logging(direction = nil)
173
- inspection_filter.filter(@__context.__combined_data.slice(*_declared_fields(direction)))
209
+ base_context = self.class.context_for_logging(
210
+ data: @__context.__combined_data,
211
+ direction:,
212
+ )
213
+
214
+ # Only merge additional context for exception logging (direction is nil)
215
+ # Pre/post logging don't need additional context since they only log inputs/outputs
216
+ return base_context if direction
217
+
218
+ # Merge both explicit setter context and hook method context (if both exist)
219
+ explicit_context = @__additional_logging_context || {}
220
+ hook_context = respond_to?(:additional_logging_context, true) ? additional_logging_context : {}
221
+ base_context.merge(explicit_context).merge(hook_context)
174
222
  end
175
223
 
176
224
  private
177
225
 
178
- def _with_contract
179
- _apply_inbound_preprocessing!
180
- _apply_defaults!(:inbound)
226
+ def _handle_early_completion_if_raised
227
+ yield
228
+ nil
229
+ rescue Axn::Internal::EarlyCompletion => e
230
+ @__context.__record_early_completion(e.message)
231
+ _trigger_on_success
232
+ true
233
+ end
234
+
235
+ def _with_contract(&)
236
+ return if _handle_early_completion_if_raised { _apply_inbound_preprocessing! }
237
+ return if _handle_early_completion_if_raised { _apply_defaults!(:inbound) }
238
+
181
239
  _validate_contract!(:inbound)
182
240
 
183
- yield
241
+ if _handle_early_completion_if_raised(&)
242
+ # Even with early completion, we need to validate outbound and apply defaults
243
+ _apply_defaults!(:outbound)
244
+ _validate_contract!(:outbound)
245
+ return
246
+ end
184
247
 
185
248
  _apply_defaults!(:outbound)
186
249
  _validate_contract!(:outbound)
187
250
 
188
251
  # TODO: improve location of this triggering
189
252
  @__context.__finalize! # Mark result as finalized
190
- _trigger_on_success if respond_to?(:_trigger_on_success)
253
+ _trigger_on_success
191
254
  end
192
255
 
193
256
  def _build_context_facade(direction)
194
257
  raise ArgumentError, "Invalid direction: #{direction}" unless %i[inbound outbound].include?(direction)
195
258
 
196
259
  klass = direction == :inbound ? Axn::InternalContext : Axn::Result
197
- implicitly_allowed_fields = direction == :inbound ? _declared_fields(:outbound) : []
198
-
199
- klass.new(action: self, context: @__context, declared_fields: _declared_fields(direction), implicitly_allowed_fields:)
200
- end
201
-
202
- def inspection_filter
203
- @inspection_filter ||= ActiveSupport::ParameterFilter.new(sensitive_fields)
204
- end
205
-
206
- def sensitive_fields
207
- (internal_field_configs + external_field_configs + subfield_configs).select(&:sensitive).map(&:field)
208
- end
260
+ implicitly_allowed_fields = direction == :inbound ? self.class._declared_fields(:outbound) : []
209
261
 
210
- def _declared_fields(direction)
211
- raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)
212
-
213
- configs = case direction
214
- when :inbound then internal_field_configs
215
- when :outbound then external_field_configs
216
- else (internal_field_configs + external_field_configs)
217
- end
218
-
219
- configs.map(&:field)
262
+ klass.new(action: self, context: @__context, declared_fields: self.class._declared_fields(direction), implicitly_allowed_fields:)
220
263
  end
221
264
  end
222
265
  end
@@ -39,7 +39,7 @@ module Axn
39
39
  _parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, optional:, preprocess:, sensitive:, default:,
40
40
  **validations).tap do |configs|
41
41
  duplicated = subfield_configs.map(&:field) & configs.map(&:field)
42
- raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(", ")}" if duplicated.any?
42
+ raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
43
43
 
44
44
  # NOTE: avoid <<, which would update value for parents and children
45
45
  self.subfield_configs += configs
@@ -10,10 +10,13 @@ module Axn
10
10
  next unless config.preprocess
11
11
 
12
12
  initial_value = @__context.provided_data[config.field]
13
- new_value = config.preprocess.call(initial_value)
14
- @__context.provided_data[config.field] = new_value
15
- rescue StandardError => e
16
- raise Axn::ContractViolation::PreprocessingError, "Error preprocessing field '#{config.field}': #{e.message}", cause: e
13
+ @__context.provided_data[config.field] = Axn::Util::ContractErrorHandling.with_contract_error_handling(
14
+ exception_class: Axn::ContractViolation::PreprocessingError,
15
+ message: ->(field, error) { "Error preprocessing field '#{field}': #{error.message}" },
16
+ field_identifier: config.field,
17
+ ) do
18
+ instance_exec(initial_value, &config.preprocess)
19
+ end
17
20
  end
18
21
 
19
22
  _apply_inbound_preprocessing_for_subfields!
@@ -57,11 +60,13 @@ module Axn
57
60
  data_hash = direction == :inbound ? @__context.provided_data : @__context.exposed_data
58
61
  next if data_hash.key?(field) && !data_hash[field].nil?
59
62
 
60
- default_value = default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
61
-
62
- data_hash[field] = default_value
63
- rescue StandardError => e
64
- raise Axn::ContractViolation::DefaultAssignmentError, "Error applying default for field '#{field}': #{e.message}", cause: e
63
+ data_hash[field] = Axn::Util::ContractErrorHandling.with_contract_error_handling(
64
+ exception_class: Axn::ContractViolation::DefaultAssignmentError,
65
+ message: ->(field_name, error) { "Error applying default for field '#{field_name}': #{error.message}" },
66
+ field_identifier: field,
67
+ ) do
68
+ default_value_getter.respond_to?(:call) ? instance_exec(&default_value_getter) : default_value_getter
69
+ end
65
70
  end
66
71
 
67
72
  # Apply subfield defaults for inbound direction
@@ -9,10 +9,14 @@ module Axn
9
9
  def _apply_inbound_preprocessing_for_subfields!
10
10
  _for_each_relevant_subfield_config(:preprocess) do |config, parent_field, subfield, parent_value|
11
11
  current_subfield_value = Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_value)
12
- preprocessed_value = config.preprocess.call(current_subfield_value)
12
+ preprocessed_value = Axn::Util::ContractErrorHandling.with_contract_error_handling(
13
+ exception_class: Axn::ContractViolation::PreprocessingError,
14
+ message: ->(_field, error) { "Error preprocessing subfield '#{config.field}' on '#{config.on}': #{error.message}" },
15
+ field_identifier: "#{config.field} on #{config.on}",
16
+ ) do
17
+ instance_exec(current_subfield_value, &config.preprocess)
18
+ end
13
19
  _update_subfield_value(parent_field, subfield, preprocessed_value)
14
- rescue StandardError => e
15
- raise Axn::ContractViolation::PreprocessingError, "Error preprocessing subfield '#{config.field}' on '#{config.on}': #{e.message}", cause: e
16
20
  end
17
21
  end
18
22
 
@@ -23,11 +27,14 @@ module Axn
23
27
 
24
28
  @__context.provided_data[parent_field] = {} if parent_value.nil?
25
29
 
26
- default_value = config.default.respond_to?(:call) ? instance_exec(&config.default) : config.default
30
+ default_value = Axn::Util::ContractErrorHandling.with_contract_error_handling(
31
+ exception_class: Axn::ContractViolation::DefaultAssignmentError,
32
+ message: ->(_field, error) { "Error applying default for subfield '#{config.field}' on '#{config.on}': #{error.message}" },
33
+ field_identifier: "#{config.field} on #{config.on}",
34
+ ) do
35
+ config.default.respond_to?(:call) ? instance_exec(&config.default) : config.default
36
+ end
27
37
  _update_subfield_value(parent_field, subfield, default_value)
28
- rescue StandardError => e
29
- raise Axn::ContractViolation::DefaultAssignmentError, "Error applying default for subfield '#{config.field}' on '#{config.on}': #{e.message}",
30
- cause: e
31
38
  end
32
39
  end
33
40
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ # Default implementation of the call method that automatically exposes
6
+ # all declared exposures by calling methods with matching names.
7
+ module DefaultCall
8
+ # User-defined action logic - override this method in your action classes
9
+ # Default implementation automatically exposes all declared exposures by calling
10
+ # methods with matching names. Raises if a method is missing and no default is provided.
11
+ def call
12
+ return if self.class.external_field_configs.empty?
13
+
14
+ exposures = {}
15
+
16
+ self.class.external_field_configs.each do |config|
17
+ field = config.field
18
+ # Check if field is optional (allow_blank or no presence validation)
19
+ is_optional = _field_is_optional?(config)
20
+
21
+ # If method exists, call it (user-defined methods override auto-generated ones)
22
+ # The auto-generated method for exposed-only fields returns nil (field not in provided_data)
23
+ next unless respond_to?(field, true)
24
+
25
+ value = send(field)
26
+ # If it returns nil and it's an exposed-only field with no default,
27
+ # it's likely the auto-generated method (user methods can also return nil, but
28
+ # we'll assume it's auto-generated in this case)
29
+ is_exposed_only = !self.class.internal_field_configs.map(&:field).include?(field)
30
+ is_not_in_provided = !@__context.provided_data.key?(field)
31
+
32
+ # Only expose if we have a value, or if it's nil but there's a default
33
+ # If it's nil and optional, don't expose - let validation handle it
34
+ if value.nil? && is_exposed_only && is_not_in_provided && config.default.nil? && !is_optional
35
+ # This is the auto-generated method returning nil for a required field
36
+ # Don't expose it - let outbound validation catch the missing exposure
37
+ else
38
+ exposures[field] = value unless value.nil? && config.default.nil?
39
+ end
40
+ # If method doesn't exist:
41
+ # - If optional, skip it - validation will handle it
42
+ # - If not optional and no default, skip it - let outbound validation catch it
43
+ # - If there's a default, skip it - the default will be applied later
44
+ end
45
+
46
+ expose(**exposures) if exposures.any?
47
+ end
48
+
49
+ private
50
+
51
+ def _field_is_optional?(config)
52
+ validations = config.validations
53
+ # Field is optional if:
54
+ # 1. It doesn't have presence: true validation (presence is the default for non-optional fields)
55
+ # 2. Any validator has allow_blank: true
56
+ return true unless validations.key?(:presence) && validations[:presence] == true
57
+
58
+ # Check if any validator has allow_blank: true
59
+ validations.values.any? { |v| v.is_a?(Hash) && v[:allow_blank] == true }
60
+ end
61
+ end
62
+ end
63
+ end
@@ -10,6 +10,7 @@ module Axn
10
10
 
11
11
  def _trigger_on_exception(exception)
12
12
  # Call any handlers registered on *this specific action* class
13
+ # (handlers can call context_for_logging themselves if needed)
13
14
  self.class._dispatch_callbacks(:exception, action: self, exception:)
14
15
 
15
16
  # Call any global handlers
@@ -32,6 +33,10 @@ module Axn
32
33
 
33
34
  def _with_exception_handling
34
35
  yield
36
+ rescue Axn::Internal::EarlyCompletion
37
+ # Early completion is not an error - it's a control flow mechanism
38
+ # It should propagate through to be handled by the result builder
39
+ raise
35
40
  rescue StandardError => e
36
41
  @__context.__record_exception(e)
37
42
 
@@ -37,13 +37,25 @@ module Axn
37
37
  def self._build_rule_for_from_condition(from_class)
38
38
  return nil unless from_class
39
39
 
40
- if from_class.is_a?(String)
41
- lambda { |exception:, **|
42
- exception.is_a?(Axn::Failure) && exception.source&.class&.name == from_class
43
- }
44
- else
45
- ->(exception:, **) { exception.is_a?(Axn::Failure) && exception.source.is_a?(from_class) }
46
- end
40
+ # Special case: `from: true` means "from any child action"
41
+ return ->(exception:, **) { exception.is_a?(Axn::Failure) && exception.source } if from_class == true
42
+
43
+ from_classes = Array(from_class)
44
+ lambda { |exception:, **|
45
+ return false unless exception.is_a?(Axn::Failure) && exception.source
46
+
47
+ source = exception.source
48
+ from_classes.any? do |cls|
49
+ if cls.is_a?(String)
50
+ # rubocop:disable Style/ClassEqualityComparison
51
+ # We're comparing class name strings, not classes themselves
52
+ source.class.name == cls
53
+ # rubocop:enable Style/ClassEqualityComparison
54
+ else
55
+ source.is_a?(cls)
56
+ end
57
+ end
58
+ }
47
59
  end
48
60
  end
49
61
  end
@@ -17,22 +17,6 @@ module Axn
17
17
  Axn::Internal::Logging.piping_error(operation, action:, exception: e)
18
18
  end
19
19
 
20
- # Shared introspection helpers
21
- def accepts_exception_keyword?(callable_or_method)
22
- return false unless callable_or_method.respond_to?(:parameters)
23
-
24
- params = callable_or_method.parameters
25
- params.any? { |type, name| %i[keyreq key].include?(type) && name == :exception } ||
26
- params.any? { |type, _| type == :keyrest }
27
- end
28
-
29
- def accepts_positional_exception?(callable_or_method)
30
- return false unless callable_or_method.respond_to?(:arity)
31
-
32
- arity = callable_or_method.arity
33
- arity == 1 || arity.negative?
34
- end
35
-
36
20
  private
37
21
 
38
22
  def symbol?(value) = value.is_a?(Symbol)
@@ -46,23 +30,13 @@ module Axn
46
30
  end
47
31
 
48
32
  method = action.method(symbol)
49
- if exception && accepts_exception_keyword?(method)
50
- action.send(symbol, exception:)
51
- elsif exception && accepts_positional_exception?(method)
52
- action.send(symbol, exception)
53
- else
54
- action.send(symbol)
55
- end
33
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
34
+ action.send(symbol, *filtered_args, **filtered_kwargs)
56
35
  end
57
36
 
58
37
  def call_callable_handler(action:, callable:, exception: nil)
59
- if exception && accepts_exception_keyword?(callable)
60
- action.instance_exec(exception:, &callable)
61
- elsif exception && accepts_positional_exception?(callable)
62
- action.instance_exec(exception, &callable)
63
- else
64
- action.instance_exec(&callable)
65
- end
38
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(callable, exception)
39
+ action.instance_exec(*filtered_args, **filtered_kwargs, &callable)
66
40
  end
67
41
 
68
42
  def literal_value(value) = value
@@ -36,25 +36,15 @@ module Axn
36
36
  def exception_class? = @rule.is_a?(Class) && @rule <= Exception
37
37
 
38
38
  def apply_callable(action:, exception:)
39
- if exception && Invoker.accepts_exception_keyword?(@rule)
40
- !!action.instance_exec(exception:, &@rule)
41
- elsif exception && Invoker.accepts_positional_exception?(@rule)
42
- !!action.instance_exec(exception, &@rule)
43
- else
44
- !!action.instance_exec(&@rule)
45
- end
39
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(@rule, exception)
40
+ !!action.instance_exec(*filtered_args, **filtered_kwargs, &@rule)
46
41
  end
47
42
 
48
43
  def apply_symbol(action:, exception:)
49
44
  if action.respond_to?(@rule)
50
45
  method = action.method(@rule)
51
- if exception && Invoker.accepts_exception_keyword?(method)
52
- !!action.public_send(@rule, exception:)
53
- elsif exception && Invoker.accepts_positional_exception?(method)
54
- !!action.public_send(@rule, exception)
55
- else
56
- !!action.public_send(@rule)
57
- end
46
+ filtered_args, filtered_kwargs = Axn::Util::Callable.only_requested_params_for_exception(method, exception)
47
+ !!action.public_send(@rule, *filtered_args, **filtered_kwargs)
58
48
  else
59
49
  begin
60
50
  klass = Object.const_get(@rule.to_s)
@@ -24,7 +24,7 @@ module Axn
24
24
  raise Axn::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
25
25
  raise Axn::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
26
26
  raise ArgumentError, "Provide either a message or a block, not both" if message && block_given?
27
- raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix]
27
+ raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix] || kwargs[:from]
28
28
  raise ArgumentError, "from: only applies to error messages" if kwargs.key?(:from) && kind != :error
29
29
 
30
30
  # If message is already a descriptor, use it directly
@@ -128,6 +128,7 @@ module Axn
128
128
  yield
129
129
  rescue Axn::Internal::EarlyCompletion => e
130
130
  @__context.__record_early_completion(e.message)
131
+ raise e
131
132
  end
132
133
  end
133
134
  end
@@ -17,20 +17,31 @@ module Axn
17
17
  module ClassMethods
18
18
  def log_level = Axn.config.log_level
19
19
 
20
- def log(message, level: log_level, before: nil, after: nil)
21
- msg = [_log_prefix, message].compact_blank.join(" ")
20
+ # @param message [String] The message to log
21
+ # @param level [Symbol] The log level (default: log_level)
22
+ # @param before [String, nil] Text to prepend to the message
23
+ # @param after [String, nil] Text to append to the message
24
+ # @param prefix [String, nil] Override the default prefix (useful for class-level logging)
25
+ def log(message, level: log_level, before: nil, after: nil, prefix: nil)
26
+ resolved_prefix = prefix.nil? ? _log_prefix : prefix
27
+ msg = [resolved_prefix, message].compact_blank.join(" ")
22
28
  msg = [before, msg, after].compact_blank.join if before || after
23
29
 
24
30
  Axn.config.logger.send(level, msg)
25
31
  end
26
32
 
27
33
  LEVELS.each do |level|
28
- define_method(level) do |message, before: nil, after: nil|
29
- log(message, level:, before:, after:)
34
+ define_method(level) do |message, before: nil, after: nil, prefix: nil|
35
+ log(message, level:, before:, after:, prefix:)
30
36
  end
31
37
  end
32
38
 
33
- def _log_prefix = "[#{name.presence || "Anonymous Class"}]"
39
+ def _log_prefix
40
+ names = NestingTracking._current_axn_stack.map do |axn|
41
+ axn.class.name.presence || "Anonymous Class"
42
+ end
43
+ "[#{names.join(' > ')}]"
44
+ end
34
45
  end
35
46
  end
36
47
  end