axn 0.1.0.pre.alpha.2.8.1 → 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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/commands/pr.md +36 -0
  3. data/.cursor/rules/axn-framework-patterns.mdc +43 -0
  4. data/.cursor/rules/general-coding-standards.mdc +27 -0
  5. data/.cursor/rules/spec/testing-patterns.mdc +40 -0
  6. data/CHANGELOG.md +57 -0
  7. data/Rakefile +114 -4
  8. data/docs/.vitepress/config.mjs +19 -10
  9. data/docs/advanced/conventions.md +3 -3
  10. data/docs/advanced/mountable.md +476 -0
  11. data/docs/advanced/profiling.md +351 -0
  12. data/docs/advanced/rough.md +27 -8
  13. data/docs/index.md +5 -3
  14. data/docs/intro/about.md +1 -1
  15. data/docs/intro/overview.md +6 -6
  16. data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
  17. data/docs/recipes/memoization.md +103 -18
  18. data/docs/recipes/rubocop-integration.md +38 -284
  19. data/docs/recipes/testing.md +14 -14
  20. data/docs/recipes/validating-user-input.md +1 -1
  21. data/docs/reference/async.md +429 -0
  22. data/docs/reference/axn-result.md +107 -0
  23. data/docs/reference/class.md +225 -64
  24. data/docs/reference/configuration.md +366 -34
  25. data/docs/reference/form-object.md +252 -0
  26. data/docs/reference/instance.md +14 -29
  27. data/docs/strategies/client.md +212 -0
  28. data/docs/strategies/form.md +235 -0
  29. data/docs/strategies/index.md +21 -21
  30. data/docs/strategies/transaction.md +1 -1
  31. data/docs/usage/setup.md +16 -2
  32. data/docs/usage/steps.md +7 -7
  33. data/docs/usage/using.md +23 -12
  34. data/docs/usage/writing.md +191 -12
  35. data/lib/axn/async/adapters/active_job.rb +74 -0
  36. data/lib/axn/async/adapters/disabled.rb +41 -0
  37. data/lib/axn/async/adapters/sidekiq.rb +67 -0
  38. data/lib/axn/async/adapters.rb +26 -0
  39. data/lib/axn/async/batch_enqueue/config.rb +38 -0
  40. data/lib/axn/async/batch_enqueue.rb +99 -0
  41. data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
  42. data/lib/axn/async.rb +178 -0
  43. data/lib/axn/configuration.rb +113 -0
  44. data/lib/{action → axn}/context.rb +22 -4
  45. data/lib/axn/core/automatic_logging.rb +89 -0
  46. data/lib/axn/core/context/facade.rb +69 -0
  47. data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
  48. data/lib/{action → axn}/core/context/internal.rb +5 -5
  49. data/lib/{action → axn}/core/contract.rb +111 -73
  50. data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
  51. data/lib/{action → axn}/core/contract_validation.rb +27 -12
  52. data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
  53. data/lib/axn/core/default_call.rb +63 -0
  54. data/lib/axn/core/field_resolvers/extract.rb +32 -0
  55. data/lib/axn/core/field_resolvers/model.rb +63 -0
  56. data/lib/axn/core/field_resolvers.rb +24 -0
  57. data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
  58. data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
  59. data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
  60. data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
  61. data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
  62. data/lib/axn/core/flow/handlers/invoker.rb +47 -0
  63. data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
  64. data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
  65. data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
  66. data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
  67. data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
  68. data/lib/axn/core/flow/handlers.rb +20 -0
  69. data/lib/{action → axn}/core/flow/messages.rb +8 -8
  70. data/lib/{action → axn}/core/flow.rb +4 -4
  71. data/lib/{action → axn}/core/hooks.rb +17 -5
  72. data/lib/axn/core/logging.rb +48 -0
  73. data/lib/axn/core/memoization.rb +53 -0
  74. data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
  75. data/lib/{action → axn}/core/timing.rb +1 -1
  76. data/lib/axn/core/tracing.rb +90 -0
  77. data/lib/axn/core/use_strategy.rb +29 -0
  78. data/lib/{action → axn}/core/validation/fields.rb +26 -2
  79. data/lib/{action → axn}/core/validation/subfields.rb +14 -12
  80. data/lib/axn/core/validation/validators/model_validator.rb +36 -0
  81. data/lib/axn/core/validation/validators/type_validator.rb +80 -0
  82. data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
  83. data/lib/{action → axn}/core.rb +55 -55
  84. data/lib/{action → axn}/exceptions.rb +12 -2
  85. data/lib/axn/extras/strategies/client.rb +150 -0
  86. data/lib/axn/extras/strategies/vernier.rb +121 -0
  87. data/lib/axn/extras.rb +4 -0
  88. data/lib/axn/factory.rb +122 -34
  89. data/lib/axn/form_object.rb +90 -0
  90. data/lib/axn/internal/logging.rb +30 -0
  91. data/lib/axn/internal/registry.rb +87 -0
  92. data/lib/axn/mountable/descriptor.rb +76 -0
  93. data/lib/axn/mountable/helpers/class_builder.rb +193 -0
  94. data/lib/axn/mountable/helpers/mounter.rb +33 -0
  95. data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
  96. data/lib/axn/mountable/helpers/validator.rb +112 -0
  97. data/lib/axn/mountable/inherit_profiles.rb +72 -0
  98. data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
  99. data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
  100. data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
  101. data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
  102. data/lib/axn/mountable/mounting_strategies.rb +32 -0
  103. data/lib/axn/mountable.rb +119 -0
  104. data/lib/axn/rails/engine.rb +51 -0
  105. data/lib/axn/rails/generators/axn_generator.rb +86 -0
  106. data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
  107. data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
  108. data/lib/{action → axn}/result.rb +32 -13
  109. data/lib/axn/strategies/form.rb +98 -0
  110. data/lib/axn/strategies/transaction.rb +26 -0
  111. data/lib/axn/strategies.rb +20 -0
  112. data/lib/axn/testing/spec_helpers.rb +6 -8
  113. data/lib/axn/util/callable.rb +120 -0
  114. data/lib/axn/util/contract_error_handling.rb +32 -0
  115. data/lib/axn/util/execution_context.rb +34 -0
  116. data/lib/axn/util/global_id_serialization.rb +52 -0
  117. data/lib/axn/util/logging.rb +87 -0
  118. data/lib/axn/util/memoization.rb +20 -0
  119. data/lib/axn/version.rb +1 -1
  120. data/lib/axn.rb +26 -16
  121. data/lib/rubocop/cop/axn/README.md +23 -23
  122. data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
  123. metadata +106 -64
  124. data/.rspec +0 -3
  125. data/.rubocop.yml +0 -76
  126. data/.tool-versions +0 -1
  127. data/docs/reference/action-result.md +0 -37
  128. data/lib/action/attachable/base.rb +0 -43
  129. data/lib/action/attachable/steps.rb +0 -63
  130. data/lib/action/attachable/subactions.rb +0 -70
  131. data/lib/action/attachable.rb +0 -17
  132. data/lib/action/configuration.rb +0 -55
  133. data/lib/action/core/automatic_logging.rb +0 -93
  134. data/lib/action/core/context/facade.rb +0 -48
  135. data/lib/action/core/flow/handlers/invoker.rb +0 -73
  136. data/lib/action/core/flow/handlers.rb +0 -20
  137. data/lib/action/core/logging.rb +0 -37
  138. data/lib/action/core/tracing.rb +0 -17
  139. data/lib/action/core/use_strategy.rb +0 -30
  140. data/lib/action/core/validation/validators/model_validator.rb +0 -34
  141. data/lib/action/core/validation/validators/type_validator.rb +0 -30
  142. data/lib/action/enqueueable/via_sidekiq.rb +0 -76
  143. data/lib/action/enqueueable.rb +0 -13
  144. data/lib/action/strategies/transaction.rb +0 -19
  145. data/lib/action/strategies.rb +0 -48
  146. data/lib/axn/util.rb +0 -24
  147. data/package.json +0 -10
  148. data/yarn.lock +0 -1166
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/mountable/inherit_profiles"
4
+ require "axn/exceptions"
5
+
6
+ module Axn
7
+ module Mountable
8
+ module Helpers
9
+ # Handles building and configuring Axn action classes for mounting
10
+ class ClassBuilder
11
+ def initialize(descriptor)
12
+ @descriptor = descriptor
13
+ end
14
+
15
+ def mount(target, name)
16
+ namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
17
+ return unless should_register_constant?(namespace)
18
+
19
+ build_and_configure_action_class(target, name, namespace)
20
+ end
21
+
22
+ def generate_constant_name(name)
23
+ name.to_s.parameterize(separator: "_").classify
24
+ end
25
+
26
+ def build_and_configure_action_class(target, name, namespace)
27
+ # Mark target as having an action class being created to prevent recursion
28
+ # This is necessary because create_superclass_for_inherit_mode may create
29
+ # classes that inherit from target, triggering the inherited callback
30
+ target.instance_variable_set(:@_axn_creating_action_class_for, target)
31
+ begin
32
+ # Pass target through to Factory.build so it can mark the superclass
33
+ # The superclass flag is checked by the inherited callback in mountable.rb
34
+ mounted_axn = build_action_class(target, _creating_action_class_for: target)
35
+ configure_class_name_and_constant(mounted_axn, name, namespace, target)
36
+ configure_axn_mounted_to(mounted_axn, target)
37
+ mounted_axn
38
+ ensure
39
+ target.instance_variable_set(:@_axn_creating_action_class_for, nil)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def build_action_class(target, _creating_action_class_for: nil) # rubocop:disable Lint/UnderscorePrefixedVariableName
46
+ existing_axn_klass = @descriptor.instance_variable_get(:@existing_axn_klass)
47
+ return existing_axn_klass if existing_axn_klass
48
+
49
+ kwargs = @descriptor.instance_variable_get(:@kwargs)
50
+ block = @descriptor.instance_variable_get(:@block)
51
+
52
+ # Remove axn_klass from kwargs as it's not a valid parameter for Factory.build
53
+ factory_kwargs = kwargs.except(:axn_klass)
54
+
55
+ unless factory_kwargs.key?(:superclass)
56
+ # Get inherit configuration
57
+ inherit_config = @descriptor.options[:inherit]
58
+
59
+ # Determine superclass based on inherit configuration
60
+ factory_kwargs[:superclass] = create_superclass_for_inherit_mode(target, inherit_config)
61
+ end
62
+
63
+ # Pass the target class through to Factory.build so it can mark the superclass
64
+ factory_kwargs[:_creating_action_class_for] = _creating_action_class_for
65
+
66
+ Axn::Factory.build(**factory_kwargs, &block)
67
+ end
68
+
69
+ def configure_class_name_and_constant(axn_klass, name, axn_namespace, target)
70
+ configure_class_name(axn_klass, name, axn_namespace) if name.present?
71
+ register_constant(axn_klass, name, axn_namespace, target) if should_register_constant?(axn_namespace)
72
+ end
73
+
74
+ def configure_axn_mounted_to(axn_klass, target)
75
+ axn_klass.define_singleton_method(:__axn_mounted_to__) { target }
76
+ axn_klass.define_method(:__axn_mounted_to__) { target }
77
+ end
78
+
79
+ def should_register_constant?(axn_namespace)
80
+ axn_namespace&.name&.end_with?("::Axns")
81
+ end
82
+
83
+ def create_superclass_for_inherit_mode(target, inherit_config)
84
+ # Handle module targets - convert to a base class first, then apply inherit mode
85
+ target = create_module_base_class(target) if target.is_a?(Module) && !target.is_a?(Class)
86
+
87
+ # Resolve inherit configuration to a hash
88
+ resolved_config = InheritProfiles.resolve(inherit_config)
89
+
90
+ # If nothing should be inherited, return Object
91
+ return Object if resolved_config.values.none?
92
+
93
+ # If everything should be inherited, return target as-is
94
+ return target if resolved_config.values.all?
95
+
96
+ # Otherwise, create a class with selective inheritance
97
+ create_class_with_selective_inheritance(target, resolved_config)
98
+ end
99
+
100
+ def create_module_base_class(target_module)
101
+ # Create a base class that includes the target module
102
+ # This allows the action class to inherit from a class while still having access to module methods
103
+ Class.new do
104
+ include target_module
105
+ end
106
+ end
107
+
108
+ def create_class_with_selective_inheritance(target, inherit_config)
109
+ # Create a class that inherits from target but selectively clears features
110
+ Class.new(target) do
111
+ # Only clear Axn-specific attributes if target includes Axn
112
+ if respond_to?(:internal_field_configs=)
113
+ # Clear fields if not inherited
114
+ unless inherit_config[:fields]
115
+ self.internal_field_configs = []
116
+ self.external_field_configs = []
117
+ end
118
+
119
+ # Clear hooks if not inherited
120
+ unless inherit_config[:hooks]
121
+ self.around_hooks = []
122
+ self.before_hooks = []
123
+ self.after_hooks = []
124
+ end
125
+
126
+ # Clear callbacks if not inherited
127
+ self._callbacks_registry = Axn::Core::Flow::Handlers::Registry.empty unless inherit_config[:callbacks]
128
+
129
+ # Clear messages if not inherited
130
+ self._messages_registry = Axn::Core::Flow::Handlers::Registry.empty unless inherit_config[:messages]
131
+
132
+ # Clear async config if not inherited (nil = use Axn.config defaults)
133
+ unless inherit_config[:async]
134
+ self._async_adapter = nil
135
+ self._async_config = nil
136
+ self._async_config_block = nil
137
+ end
138
+ end
139
+
140
+ # NOTE: Strategies are always inherited as they're mixed in via `include` and become
141
+ # part of the ancestry chain. This cannot be controlled via the inherit configuration.
142
+ end
143
+ end
144
+
145
+ def configure_class_name(axn_klass, name, axn_namespace)
146
+ class_name = name.to_s.classify
147
+
148
+ axn_klass.define_singleton_method(:name) do
149
+ # Evaluate namespace name dynamically when the method is called
150
+ current_namespace_name = axn_namespace&.name
151
+
152
+ if current_namespace_name&.end_with?("::Axns")
153
+ # We're already in a namespace, just add the method name
154
+ "#{current_namespace_name}::#{class_name}"
155
+ elsif current_namespace_name
156
+ # Create the Axns namespace
157
+ "#{current_namespace_name}::Axns::#{class_name}"
158
+ else
159
+ # Fallback for anonymous classes
160
+ "AnonymousAxn::#{class_name}"
161
+ end
162
+ end
163
+ end
164
+
165
+ def register_constant(axn_klass, name, axn_namespace, target)
166
+ constant_name = generate_constant_name(name)
167
+
168
+ # Check if constant already exists - if so, only allow overwriting in inheritance scenarios
169
+ if axn_namespace.const_defined?(constant_name, false)
170
+ # Only allow overwriting if this is an inheritance scenario (child overriding parent)
171
+ # Check if the target's parent has the same method mounted (inheritance override)
172
+ parent = target.superclass
173
+ is_inheritance_override = parent.respond_to?(:_mounted_axn_descriptors) &&
174
+ parent._mounted_axn_descriptors.any? { |d| d.name.to_s == name.to_s }
175
+
176
+ # If it's not an inheritance override, this is a same-class collision
177
+ # Raise an error here for clarity, rather than silently skipping and failing later
178
+ # Use the same error message format as mount_method for consistency
179
+ unless is_inheritance_override
180
+ method_name = "#{name}!"
181
+ strategy_name = @descriptor.mount_strategy.name.split("::").last
182
+ raise Axn::Mountable::MountingError,
183
+ "#{strategy_name} unable to attach -- method '#{method_name}' is already taken"
184
+ end
185
+ end
186
+
187
+ # Set the constant (either it doesn't exist, or it's an inheritance override)
188
+ axn_namespace.const_set(constant_name, axn_klass)
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/mountable/descriptor"
4
+
5
+ module Axn
6
+ module Mountable
7
+ module Helpers
8
+ # Helper class for mounting actions via strategies
9
+ class Mounter
10
+ # Mount an action using the specified strategy
11
+ #
12
+ # @param target [Class] The target class to mount the action to
13
+ # @param as [Symbol] The strategy to use (:axn, :method, :step, :enqueue_all)
14
+ # @param name [Symbol] The name of the action
15
+ # @param axn_klass [Class, nil] Optional existing action class
16
+ # @param kwargs [Hash] Additional strategy-specific options
17
+ # @param block [Proc] The action block
18
+ def self.mount_via_strategy(
19
+ target:,
20
+ as: :axn,
21
+ name: nil,
22
+ axn_klass: nil,
23
+ **kwargs,
24
+ &block
25
+ )
26
+ descriptor = Descriptor.new(name:, axn_klass:, as:, block:, kwargs:)
27
+ target._mounted_axn_descriptors += [descriptor]
28
+ descriptor.mount(target:)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ module Helpers
6
+ # Handles namespace management for mounting
7
+ module NamespaceManager
8
+ extend self
9
+
10
+ def get_or_create_namespace(target)
11
+ # Check if :Axns is defined directly on this class (not inherited)
12
+ if target.const_defined?(:Axns, false)
13
+ axn_class = target.const_get(:Axns)
14
+ return axn_class if axn_class.is_a?(Class)
15
+ end
16
+
17
+ # Create a fresh namespace class for this target
18
+ create_namespace_class(target).tap do |namespace_class|
19
+ target.const_set(:Axns, namespace_class) unless target.const_defined?(:Axns, false)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def create_namespace_class(client_class)
26
+ Class.new do
27
+ define_singleton_method(:__axn_mounted_to__) { client_class }
28
+
29
+ define_singleton_method(:name) do
30
+ client_name = client_class.name.presence || "AnonymousClient_#{client_class.object_id}"
31
+ "#{client_name}::Axns"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ module Helpers
6
+ # Handles validation logic for Descriptor
7
+ class Validator
8
+ def initialize(descriptor)
9
+ @descriptor = descriptor
10
+ end
11
+
12
+ def validate!
13
+ validate_name!
14
+ validate_axn_class_or_block!
15
+ validate_method_name!(@descriptor.name.to_s)
16
+ validate_superclass_and_inherit_conflict!
17
+
18
+ if @descriptor.existing_axn_klass
19
+ validate_existing_axn_class!
20
+ elsif @descriptor.block.present?
21
+ validate_callable!(@descriptor.block)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def validate_name!
28
+ name = @descriptor.name
29
+ invalid!("name must be a string or symbol") unless name.is_a?(String) || name.is_a?(Symbol)
30
+ end
31
+
32
+ def validate_axn_class_or_block!
33
+ existing_axn_klass = @descriptor.existing_axn_klass
34
+ block = @descriptor.block
35
+
36
+ invalid!("must provide either an axn class or a block") if existing_axn_klass.nil? && block.nil?
37
+
38
+ return unless existing_axn_klass.present? && block.present?
39
+
40
+ invalid!("cannot provide both an axn class and a block")
41
+ end
42
+
43
+ def validate_method_name!(method_name)
44
+ # Check for empty names
45
+ invalid!("method name cannot be empty") if method_name.nil? || method_name == ""
46
+
47
+ # Check for whitespace-only names
48
+ invalid!("method name '#{method_name}' must be convertible to a valid constant name") if method_name.strip.empty?
49
+
50
+ # Check for names that don't start with a letter (only reject numbers)
51
+ invalid!("method name '#{method_name}' must be convertible to a valid constant name") if method_name.match?(/\A[0-9]/)
52
+
53
+ # Check for method suffixes that would conflict with generated methods
54
+ return unless method_name.match?(/[!?=]/)
55
+
56
+ invalid!("method name '#{method_name}' cannot contain method suffixes")
57
+ end
58
+
59
+ def validate_callable!(callable)
60
+ return if callable.respond_to?(:call)
61
+
62
+ invalid!("block must be callable (respond to :call)")
63
+ end
64
+
65
+ def validate_superclass_and_inherit_conflict!
66
+ # Check if user explicitly provided superclass in kwargs
67
+ return unless @descriptor.kwargs.key?(:superclass)
68
+
69
+ # Get the inherit option value
70
+ inherit_option = @descriptor.options[:inherit]
71
+
72
+ # Get the default inherit value for this strategy
73
+ default_inherit = mount_strategy.default_inherit_mode
74
+
75
+ # If inherit was explicitly provided and differs from default, raise error
76
+ return if inherit_option == default_inherit
77
+
78
+ invalid!("cannot specify both 'superclass:' and 'inherit:' options - use one or the other")
79
+ end
80
+
81
+ def validate_existing_axn_class!
82
+ existing_axn_klass = @descriptor.existing_axn_klass
83
+
84
+ invalid!("axn class must be a Class") unless existing_axn_klass.is_a?(Class)
85
+
86
+ invalid!("axn class must include Axn module") unless existing_axn_klass.included_modules.include?(::Axn) || existing_axn_klass < ::Axn
87
+
88
+ # Check raw_kwargs (before preprocessing) to see if user provided any factory kwargs
89
+ # Exclude axn_klass and all strategy-specific kwargs
90
+ user_provided_kwargs = @descriptor.raw_kwargs.except(:axn_klass, *mount_strategy.strategy_specific_kwargs)
91
+
92
+ return unless user_provided_kwargs.present? && mount_strategy != MountingStrategies::Step
93
+
94
+ invalid!("was given an existing axn class and also keyword arguments - only one is allowed")
95
+ end
96
+
97
+ def mount_strategy
98
+ @descriptor.mount_strategy
99
+ end
100
+
101
+ def mounting_type_name
102
+ mount_strategy = @descriptor.mount_strategy
103
+ mount_strategy.name.split("::").last.underscore.to_s.humanize
104
+ end
105
+
106
+ def invalid!(msg)
107
+ raise MountingError, "#{mounting_type_name} #{msg}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ module InheritProfiles
6
+ # Predefined inheritance profiles for mounting strategies
7
+ PROFILES = {
8
+ # Inherits parent's lifecycle (hooks, callbacks, messages, async) but not fields
9
+ # Use this when the mounted action should participate in the parent's execution lifecycle
10
+ # but have its own independent contract
11
+ # NOTE: Strategies cannot be controlled - they're mixed in via `include` and become part of the ancestry
12
+ lifecycle: {
13
+ fields: false,
14
+ messages: true,
15
+ hooks: true,
16
+ callbacks: true,
17
+ async: true,
18
+ }.freeze,
19
+
20
+ # Only inherits async config - for utility methods like enqueue_all
21
+ # Use this when you need async capability but nothing else from the parent
22
+ async_only: {
23
+ fields: false,
24
+ messages: false,
25
+ hooks: false,
26
+ callbacks: false,
27
+ async: true,
28
+ }.freeze,
29
+
30
+ # Inherits nothing - completely standalone
31
+ # Use this when the mounted action should be completely independent from the parent
32
+ none: {
33
+ fields: false,
34
+ messages: false,
35
+ hooks: false,
36
+ callbacks: false,
37
+ async: false,
38
+ }.freeze,
39
+ }.freeze
40
+
41
+ # Resolve an inherit configuration to a full hash
42
+ # @param inherit [Symbol, Hash] The inherit configuration
43
+ # @return [Hash] A hash with all inheritance options set to true/false
44
+ def self.resolve(inherit)
45
+ case inherit
46
+ when Symbol
47
+ PROFILES.fetch(inherit) do
48
+ raise ArgumentError, "Unknown inherit profile: #{inherit.inspect}. Valid profiles: #{PROFILES.keys.join(', ')}"
49
+ end
50
+ when Hash
51
+ # Validate hash keys
52
+ invalid_keys = inherit.keys - PROFILES[:none].keys
53
+ raise ArgumentError, "Invalid inherit keys: #{invalid_keys.join(', ')}. Valid keys: #{PROFILES[:none].keys.join(', ')}" if invalid_keys.any?
54
+
55
+ # Merge with none profile to ensure all keys are present
56
+ PROFILES[:none].merge(inherit)
57
+ else
58
+ raise ArgumentError, "inherit must be a Symbol or Hash. Got: #{inherit.class}"
59
+ end
60
+ end
61
+
62
+ # Check if a specific feature should be inherited
63
+ # @param inherit [Symbol, Hash] The inherit configuration
64
+ # @param feature [Symbol] The feature to check (e.g., :hooks, :async)
65
+ # @return [Boolean]
66
+ def self.inherit?(inherit, feature)
67
+ resolved = resolve(inherit)
68
+ resolved.fetch(feature)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/exceptions"
4
+ require "axn/mountable/descriptor"
5
+
6
+ module Axn
7
+ module Mountable
8
+ class MountingStrategies
9
+ # Base module for all attachment strategies
10
+ module Base
11
+ # Hooks for strategy modules to configure themselves
12
+ def preprocess_kwargs(**kwargs) = kwargs
13
+ def strategy_specific_kwargs = [:inherit]
14
+
15
+ def default_inherit_mode = raise ArgumentError, "Strategy modules must implement default_inherit_mode"
16
+
17
+ # The actual per-strategy mounting logic
18
+ def mount(descriptor:, target:)
19
+ mount_to_namespace(descriptor:, target:)
20
+ mount_to_target(descriptor:, target:)
21
+ end
22
+
23
+ # Mount methods directly to the target class
24
+ def mount_to_target(descriptor:, target:) = raise NotImplementedError, "Strategy modules must implement mount_to_target"
25
+
26
+ def key = name.split("::").last.underscore.to_sym
27
+
28
+ # Helper method to define a method on target with collision checking
29
+ def mount_method(target:, method_name:, &)
30
+ # Check if method collision should raise an error
31
+ # We allow overriding if this is a child class with a parent that has axn methods (inheritance scenario)
32
+ # Otherwise, we raise an error for same-class method collisions
33
+ if _should_raise_method_collision_error?(target, method_name)
34
+ raise MountingError, "#{name.split('::').last} unable to attach -- method '#{method_name}' is already taken"
35
+ end
36
+
37
+ target.define_singleton_method(method_name, &)
38
+ end
39
+
40
+ # Mount methods to the namespace and register the action class
41
+ def mount_to_namespace(descriptor:, target:)
42
+ define_namespace_methods(descriptor:, target:)
43
+ # Register the action class as a constant in the namespace
44
+ action_class_builder = Helpers::ClassBuilder.new(descriptor)
45
+ action_class_builder.mount(target, descriptor.name.to_s)
46
+ end
47
+
48
+ # Define namespace methods on the target's Axns namespace
49
+ # This is public so it can be called from the inherited callback
50
+ # without re-registering the constant (which is already created by mounted_axn_for)
51
+ def define_namespace_methods(descriptor:, target:)
52
+ namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
53
+ name = descriptor.name
54
+ descriptor_ref = descriptor
55
+
56
+ # Mount methods that delegate to the cached action
57
+ namespace.define_singleton_method(name) do |**kwargs|
58
+ axn = descriptor_ref.mounted_axn_for(target:)
59
+ axn.call(**kwargs)
60
+ end
61
+
62
+ namespace.define_singleton_method("#{name}!") do |**kwargs|
63
+ axn = descriptor_ref.mounted_axn_for(target:)
64
+ axn.call!(**kwargs)
65
+ end
66
+
67
+ namespace.define_singleton_method("#{name}_async") do |**kwargs|
68
+ axn = descriptor_ref.mounted_axn_for(target:)
69
+ axn.call_async(**kwargs)
70
+ end
71
+ end
72
+
73
+ # Check if we should raise an error for method collision
74
+ # Returns true if method exists AND target is not overriding a parent's method (same-class collision)
75
+ def _should_raise_method_collision_error?(target, method_name)
76
+ return false unless target.respond_to?(method_name)
77
+
78
+ # Check if this is an inheritance override by seeing if the parent has the same method
79
+ is_inheritance_override = target.superclass&.respond_to?(method_name)
80
+
81
+ # Only raise error if it's a same-class collision (not inheritance override)
82
+ !is_inheritance_override
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ class MountingStrategies
6
+ module Axn
7
+ include Base
8
+ extend self # rubocop:disable Style/ModuleFunction -- module_function breaks inheritance
9
+
10
+ def default_inherit_mode = :lifecycle
11
+
12
+ module DSL
13
+ def mount_axn(name, axn_klass = nil, inherit: MountingStrategies::Axn.default_inherit_mode, **, &)
14
+ # mount_axn defaults to :lifecycle - participates in parent's execution lifecycle
15
+ Helpers::Mounter.mount_via_strategy(
16
+ target: self,
17
+ as: :axn,
18
+ name:,
19
+ axn_klass:,
20
+ inherit:,
21
+ **,
22
+ &
23
+ )
24
+ end
25
+ end
26
+
27
+ def mount_to_target(descriptor:, target:)
28
+ name = descriptor.name
29
+
30
+ mount_method(target:, method_name: name) do |**kwargs|
31
+ axn = descriptor.mounted_axn_for(target: self)
32
+ axn.call(**kwargs)
33
+ end
34
+
35
+ mount_method(target:, method_name: "#{name}!") do |**kwargs|
36
+ axn = descriptor.mounted_axn_for(target: self)
37
+ axn.call!(**kwargs)
38
+ end
39
+
40
+ mount_method(target:, method_name: "#{name}_async") do |**kwargs|
41
+ axn = descriptor.mounted_axn_for(target: self)
42
+ axn.call_async(**kwargs)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ class MountingStrategies
6
+ module Method
7
+ include Base
8
+ extend self
9
+
10
+ def default_inherit_mode = :lifecycle
11
+
12
+ module DSL
13
+ def mount_axn_method(name, axn_klass = nil, inherit: MountingStrategies::Method.default_inherit_mode, **, &)
14
+ # mount_axn_method defaults to :lifecycle - participates in parent's execution lifecycle
15
+ Helpers::Mounter.mount_via_strategy(
16
+ target: self,
17
+ as: :method,
18
+ name:,
19
+ axn_klass:,
20
+ inherit:,
21
+ **,
22
+ &
23
+ )
24
+ end
25
+ end
26
+
27
+ def strategy_specific_kwargs = super + [:expose_return_as]
28
+
29
+ def preprocess_kwargs(**kwargs)
30
+ # Call parent preprocessing first
31
+ processed_kwargs = super
32
+
33
+ # Methods require a return value
34
+ processed_kwargs[:expose_return_as] = processed_kwargs[:expose_return_as].presence || :value
35
+
36
+ # Methods aren't capable of returning multiple values
37
+ if processed_kwargs[:exposes].present?
38
+ raise MountingError,
39
+ "Methods aren't capable of exposing multiple values (will automatically expose return value instead)"
40
+ end
41
+
42
+ # Check for existing axn class with multiple exposed fields
43
+ if processed_kwargs[:axn_klass].present?
44
+ axn_klass = processed_kwargs[:axn_klass]
45
+ exposed_fields = axn_klass.external_field_configs.map(&:field)
46
+
47
+ if exposed_fields.size > 1
48
+ raise MountingError,
49
+ "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
50
+ "Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
51
+ end
52
+ end
53
+
54
+ processed_kwargs
55
+ end
56
+
57
+ def mount_to_target(descriptor:, target:)
58
+ name = descriptor.name
59
+
60
+ mount_method(target:, method_name: "#{name}!") do |**kwargs|
61
+ # Lazy load the action class for the current target
62
+ axn_klass = descriptor.mounted_axn_for(target: self)
63
+
64
+ # Determine expose_return_as by introspecting the axn class
65
+ exposed_fields = axn_klass.external_field_configs.map(&:field)
66
+ expose_return_as = exposed_fields.size == 1 ? exposed_fields.first : nil
67
+
68
+ result = axn_klass.call!(**kwargs)
69
+ return result if expose_return_as.nil?
70
+
71
+ result.public_send(expose_return_as)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def _determine_exposure_to_return(axn_klass)
78
+ # Introspect the axn class to determine expose_return_as
79
+ exposed_fields = axn_klass.external_field_configs.map(&:field)
80
+
81
+ case exposed_fields.size
82
+ when 0
83
+ nil # No exposed fields, return nil to avoid public_send
84
+ when 1
85
+ exposed_fields.first # Single field, assume it's expose_return_as
86
+ else
87
+ raise MountingError,
88
+ "Cannot determine expose_return_as for existing axn class with multiple exposed fields: #{exposed_fields.join(', ')}. " \
89
+ "Use a fresh block with mount_axn_method or ensure the axn class has exactly one exposed field."
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end