axn 0.1.0.pre.alpha.2.8 → 0.1.0.pre.alpha.3

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  3. data/.cursor/rules/general-coding-standards.mdc +27 -0
  4. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  5. data/CHANGELOG.md +47 -0
  6. data/Rakefile +12 -2
  7. data/docs/.vitepress/config.mjs +8 -3
  8. data/docs/advanced/conventions.md +2 -2
  9. data/docs/advanced/mountable.md +562 -0
  10. data/docs/advanced/profiling.md +355 -0
  11. data/docs/advanced/rough.md +1 -1
  12. data/docs/index.md +5 -3
  13. data/docs/intro/about.md +1 -1
  14. data/docs/intro/overview.md +5 -5
  15. data/docs/recipes/memoization.md +2 -2
  16. data/docs/recipes/rubocop-integration.md +38 -284
  17. data/docs/recipes/testing.md +14 -14
  18. data/docs/recipes/validating-user-input.md +1 -1
  19. data/docs/reference/async.md +160 -0
  20. data/docs/reference/axn-result.md +107 -0
  21. data/docs/reference/class.md +123 -25
  22. data/docs/reference/configuration.md +191 -10
  23. data/docs/reference/instance.md +14 -29
  24. data/docs/strategies/index.md +21 -21
  25. data/docs/strategies/transaction.md +1 -1
  26. data/docs/usage/setup.md +14 -0
  27. data/docs/usage/steps.md +7 -7
  28. data/docs/usage/using.md +23 -12
  29. data/docs/usage/writing.md +92 -11
  30. data/lib/axn/async/adapters/active_job.rb +65 -0
  31. data/lib/axn/async/adapters/disabled.rb +26 -0
  32. data/lib/axn/async/adapters/sidekiq.rb +74 -0
  33. data/lib/axn/async/adapters.rb +26 -0
  34. data/lib/axn/async.rb +61 -0
  35. data/lib/{action → axn}/configuration.rb +21 -3
  36. data/lib/{action → axn}/context.rb +21 -4
  37. data/lib/{action → axn}/core/automatic_logging.rb +6 -6
  38. data/lib/axn/core/context/facade.rb +69 -0
  39. data/lib/{action → axn}/core/context/facade_inspector.rb +31 -4
  40. data/lib/{action → axn}/core/context/internal.rb +5 -5
  41. data/lib/{action → axn}/core/contract.rb +43 -46
  42. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  43. data/lib/{action → axn}/core/contract_validation.rb +16 -6
  44. data/lib/axn/core/contract_validation_for_subfields.rb +158 -0
  45. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  46. data/lib/axn/core/field_resolvers/model.rb +63 -0
  47. data/lib/axn/core/field_resolvers.rb +24 -0
  48. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  49. data/lib/{action → axn}/core/flow/exception_execution.rb +4 -13
  50. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  51. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  52. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +6 -6
  53. data/lib/{action → axn}/core/flow/handlers/invoker.rb +6 -6
  54. data/lib/{action → axn}/core/flow/handlers/matcher.rb +5 -5
  55. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  56. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  57. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  58. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  59. data/lib/axn/core/flow/handlers.rb +20 -0
  60. data/lib/{action → axn}/core/flow/messages.rb +7 -7
  61. data/lib/{action → axn}/core/flow.rb +4 -4
  62. data/lib/{action → axn}/core/hooks.rb +16 -5
  63. data/lib/{action → axn}/core/logging.rb +3 -3
  64. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  65. data/lib/axn/core/profiling.rb +124 -0
  66. data/lib/{action → axn}/core/timing.rb +1 -1
  67. data/lib/axn/core/tracing.rb +17 -0
  68. data/lib/axn/core/use_strategy.rb +29 -0
  69. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  70. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  71. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  72. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  73. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  74. data/lib/axn/core.rb +123 -0
  75. data/lib/{action → axn}/exceptions.rb +12 -2
  76. data/lib/axn/factory.rb +102 -34
  77. data/lib/axn/internal/logging.rb +26 -0
  78. data/lib/axn/internal/registry.rb +87 -0
  79. data/lib/axn/mountable/descriptor.rb +76 -0
  80. data/lib/axn/mountable/helpers/class_builder.rb +162 -0
  81. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  82. data/lib/axn/mountable/helpers/namespace_manager.rb +66 -0
  83. data/lib/axn/mountable/helpers/validator.rb +112 -0
  84. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  85. data/lib/axn/mountable/mounting_strategies/_base.rb +83 -0
  86. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  87. data/lib/axn/mountable/mounting_strategies/enqueue_all.rb +55 -0
  88. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  89. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  90. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  91. data/lib/axn/mountable.rb +85 -0
  92. data/lib/axn/rails/engine.rb +51 -0
  93. data/lib/axn/rails/generators/axn_generator.rb +68 -0
  94. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  95. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  96. data/lib/{action → axn}/result.rb +30 -11
  97. data/lib/{action → axn}/strategies/transaction.rb +1 -1
  98. data/lib/axn/strategies.rb +20 -0
  99. data/lib/axn/testing/spec_helpers.rb +6 -8
  100. data/lib/axn/util/memoization.rb +20 -0
  101. data/lib/axn/version.rb +1 -1
  102. data/lib/axn.rb +17 -16
  103. data/lib/rubocop/cop/axn/README.md +23 -23
  104. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  105. metadata +88 -64
  106. data/.rspec +0 -3
  107. data/.rubocop.yml +0 -76
  108. data/.tool-versions +0 -1
  109. data/docs/reference/action-result.md +0 -37
  110. data/lib/action/attachable/base.rb +0 -43
  111. data/lib/action/attachable/steps.rb +0 -63
  112. data/lib/action/attachable/subactions.rb +0 -70
  113. data/lib/action/attachable.rb +0 -17
  114. data/lib/action/core/context/facade.rb +0 -48
  115. data/lib/action/core/flow/handlers.rb +0 -20
  116. data/lib/action/core/tracing.rb +0 -17
  117. data/lib/action/core/use_strategy.rb +0 -30
  118. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  119. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  120. data/lib/action/core.rb +0 -108
  121. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  122. data/lib/action/enqueueable.rb +0 -13
  123. data/lib/action/strategies.rb +0 -48
  124. data/lib/axn/util.rb +0 -24
  125. data/package.json +0 -10
  126. data/yarn.lock +0 -1166
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -16,7 +16,7 @@ module Action
16
16
  result = matches?(exception:, action:)
17
17
  @invert ? !result : result
18
18
  rescue StandardError => e
19
- Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
19
+ Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
20
20
  end
21
21
 
22
22
  private
@@ -92,7 +92,7 @@ module Action
92
92
  def call(exception:, action:)
93
93
  matches?(exception:, action:)
94
94
  rescue StandardError => e
95
- Axn::Util.piping_error("determining if handler applies to exception", action:, exception: e)
95
+ Axn::Internal::Logging.piping_error("determining if handler applies to exception", action:, exception: e)
96
96
  end
97
97
 
98
98
  def static? = @rules.empty?
@@ -103,7 +103,7 @@ module Action
103
103
  if_condition = binding.local_variable_get(:if)
104
104
  unless_condition = binding.local_variable_get(:unless)
105
105
 
106
- raise Action::UnsupportedArgument, "providing both :if and :unless" if if_condition && unless_condition
106
+ raise Axn::UnsupportedArgument, "providing both :if and :unless" if if_condition && unless_condition
107
107
 
108
108
  new(Array(if_condition || unless_condition).compact, invert: !!unless_condition)
109
109
  end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Flow
6
6
  module Handlers
7
7
  # Small, immutable, copy-on-write registry keyed by event_type.
8
8
  # Stores arrays of entries (handlers/interceptors) in insertion order.
9
+ #
10
+ # NOTE: serves different need than user-mutable e.g. Axn::Async::Adapters
9
11
  class Registry
10
12
  def self.empty = new({})
11
13
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Flow
6
6
  module Handlers
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers/invoker"
3
+ require "axn/core/flow/handlers/invoker"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Handlers
@@ -33,7 +33,7 @@ module Action
33
33
  message = resolved_message_body(descriptor)
34
34
  return nil unless message.present?
35
35
 
36
- descriptor.prefix ? "#{descriptor.prefix}#{message}" : message
36
+ "#{resolved_prefix(descriptor)}#{message}"
37
37
  end
38
38
 
39
39
  def resolved_message_body(descriptor)
@@ -49,6 +49,15 @@ module Action
49
49
  end
50
50
  end
51
51
 
52
+ def resolved_prefix(descriptor)
53
+ return nil unless descriptor.prefix
54
+ return descriptor.prefix if descriptor.prefix.is_a?(String)
55
+
56
+ Invoker.call(action:, handler: descriptor.prefix, exception:, operation: "determining prefix callable")
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
52
61
  def invoke_handler(handler) = handler ? Invoker.call(operation: "determining message callable", action:, handler:, exception:).presence : nil
53
62
  def fallback_message = event_type == :success ? DEFAULT_SUCCESS : DEFAULT_ERROR
54
63
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Flow
6
+ module Handlers
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ require "axn/core/flow/handlers/base_descriptor"
13
+ require "axn/core/flow/handlers/matcher"
14
+ require "axn/core/flow/handlers/resolvers/base_resolver"
15
+ require "axn/core/flow/handlers/descriptors/message_descriptor"
16
+ require "axn/core/flow/handlers/descriptors/callback_descriptor"
17
+ require "axn/core/flow/handlers/invoker"
18
+ require "axn/core/flow/handlers/resolvers/callback_resolver"
19
+ require "axn/core/flow/handlers/registry"
20
+ require "axn/core/flow/handlers/resolvers/message_resolver"
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/handlers"
3
+ require "axn/core/flow/handlers"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Flow
8
8
  module Messages
9
9
  def self.included(base)
10
10
  base.class_eval do
11
- class_attribute :_messages_registry, default: Action::Core::Flow::Handlers::Registry.empty
11
+ class_attribute :_messages_registry, default: Axn::Core::Flow::Handlers::Registry.empty
12
12
 
13
13
  extend ClassMethods
14
14
  end
@@ -21,19 +21,19 @@ module Action
21
21
  private
22
22
 
23
23
  def _add_message(kind, message:, **kwargs, &block)
24
- raise Action::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
25
- raise Action::UnsupportedArgument, "Combining from: with if: or unless:" if kwargs.key?(:from) && (kwargs.key?(:if) || kwargs.key?(:unless))
24
+ raise Axn::UnsupportedArgument, "calling #{kind} with both :if and :unless" if kwargs.key?(:if) && kwargs.key?(:unless)
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
27
  raise ArgumentError, "Provide a message, block, or prefix" unless message || block_given? || kwargs[:prefix]
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
31
- entry = if message.is_a?(Action::Core::Flow::Handlers::Descriptors::MessageDescriptor)
31
+ entry = if message.is_a?(Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor)
32
32
  raise ArgumentError, "Cannot pass additional configuration with prebuilt descriptor" if kwargs.any? || block_given?
33
33
 
34
34
  message
35
35
  else
36
- Action::Core::Flow::Handlers::Descriptors::MessageDescriptor.build(
36
+ Axn::Core::Flow::Handlers::Descriptors::MessageDescriptor.build(
37
37
  handler: block_given? ? block : message,
38
38
  **kwargs,
39
39
  )
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action/core/flow/messages"
4
- require "action/core/flow/callbacks"
5
- require "action/core/flow/exception_execution"
3
+ require "axn/core/flow/messages"
4
+ require "axn/core/flow/callbacks"
5
+ require "axn/core/flow/exception_execution"
6
6
 
7
- module Action
7
+ module Axn
8
8
  module Core
9
9
  module Flow
10
10
  def self.included(base)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Hooks
6
6
  def self.included(base)
@@ -68,10 +68,15 @@ module Action
68
68
  private
69
69
 
70
70
  def _with_hooks
71
- _run_around_hooks do
72
- _run_before_hooks
73
- yield
74
- _run_after_hooks
71
+ # Outer is needed in the unlikely case done! is called in around hooks
72
+ __respecting_early_completion do
73
+ _run_around_hooks do
74
+ __respecting_early_completion do
75
+ _run_before_hooks
76
+ yield
77
+ _run_after_hooks
78
+ end
79
+ end
75
80
  end
76
81
  end
77
82
 
@@ -118,6 +123,12 @@ module Action
118
123
  def _run_hook(hook, *)
119
124
  hook.is_a?(Symbol) ? send(hook, *) : instance_exec(*, &hook)
120
125
  end
126
+
127
+ def __respecting_early_completion
128
+ yield
129
+ rescue Axn::Internal::EarlyCompletion => e
130
+ @__context.__record_early_completion(e.message)
131
+ end
121
132
  end
122
133
  end
123
134
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "active_support/core_ext/module/delegation"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Core
7
7
  module Logging
8
8
  LEVELS = %i[debug info warn error fatal].freeze
@@ -15,13 +15,13 @@ module Action
15
15
  end
16
16
 
17
17
  module ClassMethods
18
- def log_level = Action.config.log_level
18
+ def log_level = Axn.config.log_level
19
19
 
20
20
  def log(message, level: log_level, before: nil, after: nil)
21
21
  msg = [_log_prefix, message].compact_blank.join(" ")
22
22
  msg = [before, msg, after].compact_blank.join if before || after
23
23
 
24
- Action.config.logger.send(level, msg)
24
+ Axn.config.logger.send(level, msg)
25
25
  end
26
26
 
27
27
  LEVELS.each do |level|
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module NestingTracking
6
6
  def self.included(base)
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "axn/core/flow/handlers/invoker"
5
+
6
+ module Axn
7
+ module Core
8
+ module Profiling
9
+ def self.included(base)
10
+ base.class_eval do
11
+ class_attribute :_profiling_enabled, default: false
12
+ class_attribute :_profiling_condition, default: nil
13
+ class_attribute :_profiling_sample_rate, default: 0.1
14
+ class_attribute :_profiling_output_dir, default: nil
15
+
16
+ extend ClassMethods
17
+ end
18
+ end
19
+
20
+ module ClassMethods
21
+ # Enable profiling for this action class
22
+ #
23
+ # @param if [Proc, Symbol, #call, nil] Optional condition to determine when to profile
24
+ # @param sample_rate [Float] Sampling rate (0.0 to 1.0, default: 0.1)
25
+ # @param output_dir [String, Pathname] Output directory for profile files (default: Rails.root/tmp/profiles or tmp/profiles)
26
+ # @return [void]
27
+ def profile(if: nil, sample_rate: 0.1, output_dir: nil)
28
+ self._profiling_enabled = true
29
+ self._profiling_condition = binding.local_variable_get(:if)
30
+ self._profiling_sample_rate = sample_rate
31
+ self._profiling_output_dir = output_dir || _default_profiling_output_dir
32
+ end
33
+
34
+ private
35
+
36
+ def _default_profiling_output_dir
37
+ if defined?(Rails) && Rails.respond_to?(:root)
38
+ Rails.root.join("tmp", "profiles")
39
+ else
40
+ Pathname.new("tmp/profiles")
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def _with_profiling(&)
48
+ # Check if this specific action should be profiled
49
+ return yield unless _should_profile?
50
+
51
+ _profile_with_vernier(&)
52
+ end
53
+
54
+ def _profile_with_vernier(&)
55
+ _ensure_vernier_available!
56
+
57
+ class_name = self.class.name.presence || "AnonymousAction"
58
+ profile_name = "axn_#{class_name}_#{Time.now.to_i}"
59
+
60
+ # Ensure output directory exists (only once per instance)
61
+ _ensure_output_directory_exists
62
+
63
+ # Build output file path
64
+ output_dir = self.class._profiling_output_dir || _default_profiling_output_dir
65
+ output_file = File.join(output_dir, "#{profile_name}.json")
66
+
67
+ # Configure Vernier with our settings
68
+ collector_options = {
69
+ out: output_file,
70
+ allocation_sample_rate: (self.class._profiling_sample_rate * 1000).to_i,
71
+ }
72
+
73
+ Vernier.profile(**collector_options, &)
74
+ end
75
+
76
+ def _ensure_output_directory_exists
77
+ return if @_profiling_directory_created
78
+
79
+ output_dir = self.class._profiling_output_dir || _default_profiling_output_dir
80
+ FileUtils.mkdir_p(output_dir)
81
+ @_profiling_directory_created = true
82
+ end
83
+
84
+ def _should_profile?
85
+ # Fast path: check if action has profiling enabled
86
+ return false unless self.class._profiling_enabled
87
+
88
+ # Fast path: no condition means always profile
89
+ return true unless self.class._profiling_condition
90
+
91
+ # Slow path: evaluate condition (only when needed)
92
+ Axn::Core::Flow::Handlers::Invoker.call(
93
+ action: self,
94
+ handler: self.class._profiling_condition,
95
+ operation: "determining if profiling should run",
96
+ )
97
+ end
98
+
99
+ def _ensure_vernier_available!
100
+ return if defined?(Vernier) && Vernier.is_a?(Module)
101
+
102
+ begin
103
+ require "vernier"
104
+ rescue LoadError
105
+ raise LoadError, <<~ERROR
106
+ Vernier profiler is not available. To use profiling, add 'vernier' to your Gemfile:
107
+
108
+ gem 'vernier', '~> 0.1'
109
+
110
+ Then run: bundle install
111
+ ERROR
112
+ end
113
+ end
114
+
115
+ def _default_profiling_output_dir
116
+ if defined?(Rails) && Rails.respond_to?(:root)
117
+ Rails.root.join("tmp", "profiles")
118
+ else
119
+ Pathname.new("tmp/profiles")
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Core
5
5
  module Timing
6
6
  def self.included(base)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module Tracing
6
+ private
7
+
8
+ def _with_tracing(&)
9
+ return yield unless Axn.config.wrap_with_trace
10
+
11
+ Axn.config.wrap_with_trace.call(self.class.name || "AnonymousClass", &)
12
+ rescue StandardError => e
13
+ Axn::Internal::Logging.piping_error("running trace hook", action: self, exception: e)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Core
5
+ module UseStrategy
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def use(strategy_name, **config, &block)
10
+ strategy = Axn::Strategies.find(strategy_name)
11
+ raise ArgumentError, "Strategy #{strategy_name} does not support config" if config.any? && !strategy.respond_to?(:configure)
12
+
13
+ # Allow dynamic configuration of strategy (i.e. dynamically define module before returning)
14
+ if strategy.respond_to?(:configure)
15
+ configured = strategy.configure(**config, &block)
16
+ raise ArgumentError, "Strategy #{strategy_name} configure method must return a module" unless configured.is_a?(Module)
17
+
18
+ strategy = configured
19
+ else
20
+ raise ArgumentError, "Strategy #{strategy_name} does not support config (define #configure method)" if config.any?
21
+ raise ArgumentError, "Strategy #{strategy_name} does not support blocks (define #configure method)" if block_given?
22
+ end
23
+
24
+ include strategy
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Action
3
+ module Axn
4
4
  module Validation
5
5
  class Fields
6
6
  include ActiveModel::Validations
@@ -20,9 +20,25 @@ module Action
20
20
  @context.public_send(attr)
21
21
  end
22
22
 
23
+ def method_missing(method_name, ...)
24
+ # Delegate method calls to the action instance to support symbol-based validations
25
+ # like inclusion: { in: :valid_channels_for_number }
26
+ action = _action_for_validation
27
+ return super unless action && action.respond_to?(method_name, true) # rubocop:disable Style/SafeNavigation
28
+
29
+ action.send(method_name, ...)
30
+ end
31
+
32
+ def respond_to_missing?(method_name, include_private = false)
33
+ action = _action_for_validation
34
+ return super unless action
35
+
36
+ action.respond_to?(method_name, include_private) || super
37
+ end
38
+
23
39
  def self.validate!(validations:, context:, exception_klass:)
24
40
  validator = Class.new(self) do
25
- def self.name = "Action::Validation::Fields::OneOff"
41
+ def self.name = "Axn::Validation::Fields::OneOff"
26
42
 
27
43
  validations.each do |field, field_validations|
28
44
  field_validations.each do |key, value|
@@ -35,6 +51,14 @@ module Action
35
51
 
36
52
  raise exception_klass, validator.errors
37
53
  end
54
+
55
+ private
56
+
57
+ def _action_for_validation
58
+ return unless @context.respond_to?(:action, true)
59
+
60
+ @context.send(:action)
61
+ end
38
62
  end
39
63
  end
40
64
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "active_support/core_ext/hash/indifferent_access"
4
4
 
5
- module Action
5
+ module Axn
6
6
  module Validation
7
7
  class Subfields
8
8
  include ActiveModel::Validations
@@ -17,24 +17,26 @@ module Action
17
17
  end
18
18
 
19
19
  def read_attribute_for_validation(attr)
20
- self.class.extract(attr, @source)
20
+ # Only use action's reader methods for model fields that need special resolution
21
+ # For all other fields, use the unified FieldResolvers system
22
+ if @action && @validations&.key?(:model) && @action.respond_to?(attr)
23
+ @action.public_send(attr)
24
+ else
25
+ Axn::Core::FieldResolvers.resolve(type: :extract, field: attr, provided_data: @source)
26
+ end
21
27
  end
22
28
 
23
- def self.extract(attr, source)
24
- return source.public_send(attr) if source.respond_to?(attr)
25
- raise "Unclear how to extract #{attr} from #{source.inspect}" unless source.respond_to?(:dig)
26
-
27
- base = source.respond_to?(:with_indifferent_access) ? source.with_indifferent_access : source
28
- base.dig(*attr.to_s.split("."))
29
- end
30
-
31
- def self.validate!(field:, validations:, source:, exception_klass:)
29
+ def self.validate!(field:, validations:, source:, exception_klass:, action: nil)
32
30
  validator = Class.new(self) do
33
- def self.name = "Action::Validation::Subfields::OneOff"
31
+ def self.name = "Axn::Validation::Subfields::OneOff"
34
32
 
35
33
  validates field, **validations
36
34
  end.new(source)
37
35
 
36
+ # Set the action context for model field resolution
37
+ validator.instance_variable_set(:@action, action)
38
+ validator.instance_variable_set(:@validations, validations)
39
+
38
40
  return if validator.valid?
39
41
 
40
42
  raise exception_klass, validator.errors
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Axn
6
+ module Validators
7
+ class ModelValidator < ActiveModel::EachValidator
8
+ # Syntactic sugar: model: User -> model: { klass: User }
9
+ def self.apply_syntactic_sugar(value, fields)
10
+ (value.is_a?(Hash) ? value.dup : { klass: value }).tap do |options|
11
+ # Set default klass based on field name if not provided
12
+ options[:klass] = nil if options[:klass] == true
13
+ options[:klass] ||= fields.first.to_s.classify
14
+
15
+ # Constantize string klass names
16
+ options[:klass] = options[:klass].constantize if options[:klass].is_a?(String)
17
+
18
+ # Set default finder if not provided
19
+ options[:finder] ||= :find
20
+ end
21
+ end
22
+
23
+ def check_validity!
24
+ return unless options[:klass].nil?
25
+
26
+ raise ArgumentError, "must supply :klass"
27
+ end
28
+
29
+ def validate_each(record, attribute, value)
30
+ # The value is already resolved by the facade, just validate the type
31
+ type_validator = TypeValidator.new(attributes: [attribute], **options)
32
+ type_validator.validate_each(record, attribute, value)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Axn
6
+ module Validators
7
+ class TypeValidator < ActiveModel::EachValidator
8
+ def self.apply_syntactic_sugar(value, _fields)
9
+ return value if value.is_a?(Hash)
10
+
11
+ { klass: value }
12
+ end
13
+
14
+ def check_validity!
15
+ raise ArgumentError, "must supply :klass" if options[:klass].nil?
16
+ end
17
+
18
+ # NOTE: we override the default validate method to allow for custom allow_blank logic
19
+ # (e.g. type: Hash should fail if given false or "", but by default EachValidator would skip)
20
+ def validate(record)
21
+ attributes.each do |attribute|
22
+ value = record.read_attribute_for_validation(attribute)
23
+ validate_each(record, attribute, value)
24
+ end
25
+ end
26
+
27
+ def validate_each(record, attribute, value)
28
+ # Custom allow_blank logic: only skip validation for nil, not other blank values
29
+ return if value.nil? && (options[:allow_nil] || options[:allow_blank])
30
+
31
+ # Check if any of the types are valid
32
+ valid = types.any? do |type|
33
+ valid_type?(type:, value:, allow_blank: options[:allow_blank])
34
+ end
35
+
36
+ record.errors.add attribute, (options[:message] || msg) unless valid
37
+ end
38
+
39
+ private
40
+
41
+ def types = Array(options[:klass])
42
+ def msg = types.size == 1 ? "is not a #{types.first}" : "is not one of #{types.join(", ")}"
43
+
44
+ def valid_type?(type:, value:, allow_blank:)
45
+ # NOTE: allow mocks to pass type validation by default (much easier testing ergonomics)
46
+ return true if Axn.config.env.test? && value.class.name&.start_with?("RSpec::Mocks::")
47
+
48
+ case type
49
+ when :boolean
50
+ boolean_type?(value)
51
+ when :uuid
52
+ uuid_type?(value, allow_blank:)
53
+ when :params
54
+ params_type?(value)
55
+ else
56
+ class_type?(type, value)
57
+ end
58
+ end
59
+
60
+ def boolean_type?(value)
61
+ [true, false].include?(value)
62
+ end
63
+
64
+ def uuid_type?(value, allow_blank: false)
65
+ return false unless value.is_a?(String)
66
+ return true if value.blank? && allow_blank
67
+
68
+ value.match?(/\A[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\z/i)
69
+ end
70
+
71
+ def params_type?(value)
72
+ value.is_a?(Hash) || (defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters))
73
+ end
74
+
75
+ def class_type?(type, value)
76
+ value.is_a?(type)
77
+ end
78
+ end
79
+ end
80
+ end