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
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn/mountable/inherit_profiles"
4
+
5
+ module Axn
6
+ module Mountable
7
+ module Helpers
8
+ # Handles building and configuring Axn action classes for mounting
9
+ class ClassBuilder
10
+ def initialize(descriptor)
11
+ @descriptor = descriptor
12
+ end
13
+
14
+ def mount(target, name)
15
+ namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
16
+ return unless should_register_constant?(namespace)
17
+
18
+ build_and_configure_action_class(target, name, namespace)
19
+ end
20
+
21
+ def generate_constant_name(name)
22
+ name.to_s.parameterize(separator: "_").classify
23
+ end
24
+
25
+ def build_and_configure_action_class(target, name, namespace)
26
+ mounted_axn = build_action_class(target)
27
+ configure_class_name_and_constant(mounted_axn, name, namespace)
28
+ configure_axn_mounted_to(mounted_axn, target)
29
+ mounted_axn
30
+ end
31
+
32
+ private
33
+
34
+ def build_action_class(target)
35
+ existing_axn_klass = @descriptor.instance_variable_get(:@existing_axn_klass)
36
+ return existing_axn_klass if existing_axn_klass
37
+
38
+ kwargs = @descriptor.instance_variable_get(:@kwargs)
39
+ block = @descriptor.instance_variable_get(:@block)
40
+
41
+ # Remove axn_klass from kwargs as it's not a valid parameter for Factory.build
42
+ factory_kwargs = kwargs.except(:axn_klass)
43
+
44
+ unless factory_kwargs.key?(:superclass)
45
+ # Get inherit configuration
46
+ inherit_config = @descriptor.options[:inherit]
47
+
48
+ # Determine superclass based on inherit configuration
49
+ factory_kwargs[:superclass] = create_superclass_for_inherit_mode(target, inherit_config)
50
+ end
51
+
52
+ Axn::Factory.build(**factory_kwargs, &block)
53
+ end
54
+
55
+ def configure_class_name_and_constant(axn_klass, name, axn_namespace)
56
+ configure_class_name(axn_klass, name, axn_namespace) if name.present?
57
+ register_constant(axn_klass, name, axn_namespace) if should_register_constant?(axn_namespace)
58
+ end
59
+
60
+ def configure_axn_mounted_to(axn_klass, target)
61
+ axn_klass.define_singleton_method(:__axn_mounted_to__) { target }
62
+ axn_klass.define_method(:__axn_mounted_to__) { target }
63
+ end
64
+
65
+ def should_register_constant?(axn_namespace)
66
+ axn_namespace&.name&.end_with?("::Axns")
67
+ end
68
+
69
+ def create_superclass_for_inherit_mode(target, inherit_config)
70
+ # Handle module targets - convert to a base class first, then apply inherit mode
71
+ target = create_module_base_class(target) if target.is_a?(Module) && !target.is_a?(Class)
72
+
73
+ # Resolve inherit configuration to a hash
74
+ resolved_config = InheritProfiles.resolve(inherit_config)
75
+
76
+ # If nothing should be inherited, return Object
77
+ return Object if resolved_config.values.none?
78
+
79
+ # If everything should be inherited, return target as-is
80
+ return target if resolved_config.values.all?
81
+
82
+ # Otherwise, create a class with selective inheritance
83
+ create_class_with_selective_inheritance(target, resolved_config)
84
+ end
85
+
86
+ def create_module_base_class(target_module)
87
+ # Create a base class that includes the target module
88
+ # This allows the action class to inherit from a class while still having access to module methods
89
+ Class.new do
90
+ include target_module
91
+ end
92
+ end
93
+
94
+ def create_class_with_selective_inheritance(target, inherit_config)
95
+ # Create a class that inherits from target but selectively clears features
96
+ Class.new(target) do
97
+ # Only clear Axn-specific attributes if target includes Axn
98
+ if respond_to?(:internal_field_configs=)
99
+ # Clear fields if not inherited
100
+ unless inherit_config[:fields]
101
+ self.internal_field_configs = []
102
+ self.external_field_configs = []
103
+ end
104
+
105
+ # Clear hooks if not inherited
106
+ unless inherit_config[:hooks]
107
+ self.around_hooks = []
108
+ self.before_hooks = []
109
+ self.after_hooks = []
110
+ end
111
+
112
+ # Clear callbacks if not inherited
113
+ self._callbacks_registry = Axn::Core::Flow::Handlers::Registry.empty unless inherit_config[:callbacks]
114
+
115
+ # Clear messages if not inherited
116
+ self._messages_registry = Axn::Core::Flow::Handlers::Registry.empty unless inherit_config[:messages]
117
+
118
+ # Clear async config if not inherited (nil = use Axn.config defaults)
119
+ unless inherit_config[:async]
120
+ self._async_adapter = nil
121
+ self._async_config = nil
122
+ self._async_config_block = nil
123
+ end
124
+ end
125
+
126
+ # NOTE: Strategies are always inherited as they're mixed in via `include` and become
127
+ # part of the ancestry chain. This cannot be controlled via the inherit configuration.
128
+ end
129
+ end
130
+
131
+ def configure_class_name(axn_klass, name, axn_namespace)
132
+ class_name = name.to_s.classify
133
+
134
+ axn_klass.define_singleton_method(:name) do
135
+ # Evaluate namespace name dynamically when the method is called
136
+ current_namespace_name = axn_namespace&.name
137
+
138
+ if current_namespace_name&.end_with?("::Axns")
139
+ # We're already in a namespace, just add the method name
140
+ "#{current_namespace_name}::#{class_name}"
141
+ elsif current_namespace_name
142
+ # Create the Axns namespace
143
+ "#{current_namespace_name}::Axns::#{class_name}"
144
+ else
145
+ # Fallback for anonymous classes
146
+ "AnonymousAxn::#{class_name}"
147
+ end
148
+ end
149
+ end
150
+
151
+ def register_constant(axn_klass, name, axn_namespace)
152
+ constant_name = generate_constant_name(name)
153
+
154
+ # Only set the constant if it doesn't exist
155
+ return if axn_namespace.const_defined?(constant_name, false)
156
+
157
+ axn_namespace.const_set(constant_name, axn_klass)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ 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,66 @@
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 namespace class that inherits from parent's Axns if available
18
+ client_class = target
19
+ parent_axns = find_parent_axns_namespace(client_class)
20
+ namespace_class = create_namespace_class(client_class, parent_axns)
21
+
22
+ # Only set the constant if it doesn't exist
23
+ return if target.const_defined?(:Axns, false)
24
+
25
+ target.const_set(:Axns, namespace_class)
26
+ end
27
+
28
+ private
29
+
30
+ def find_parent_axns_namespace(client_class)
31
+ return nil unless client_class.is_a?(Class)
32
+ return nil unless client_class.superclass.respond_to?(:_mounted_axn_descriptors)
33
+ return nil unless client_class.superclass.const_defined?(:Axns, false)
34
+
35
+ client_class.superclass.const_get(:Axns)
36
+ end
37
+
38
+ def create_namespace_class(client_class, parent_axns)
39
+ base_class = parent_axns || Class.new
40
+
41
+ Class.new(base_class) do
42
+ define_singleton_method(:__axn_mounted_to__) { client_class }
43
+
44
+ define_singleton_method(:name) do
45
+ client_name = client_class.name.presence || "AnonymousClient_#{client_class.object_id}"
46
+ "#{client_name}::Axns"
47
+ end
48
+ end.tap do |ns|
49
+ update_inherited_action_classes(ns, client_class) if parent_axns
50
+ end
51
+ end
52
+
53
+ def update_inherited_action_classes(namespace, client_class)
54
+ namespace.constants.each do |const_name|
55
+ const_value = namespace.const_get(const_name)
56
+ next unless const_value.is_a?(Class) && const_value.respond_to?(:__axn_mounted_to__)
57
+
58
+ # Update __axn_mounted_to__ method on the existing class
59
+ const_value.define_singleton_method(:__axn_mounted_to__) { client_class }
60
+ const_value.define_method(:__axn_mounted_to__) { client_class }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ 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,83 @@
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
+ private
41
+
42
+ # Mount methods to the namespace and register the action class
43
+ def mount_to_namespace(descriptor:, target:)
44
+ action_class_builder = Helpers::ClassBuilder.new(descriptor)
45
+ namespace = Helpers::NamespaceManager.get_or_create_namespace(target)
46
+ name = descriptor.name
47
+ descriptor_ref = descriptor
48
+
49
+ # Mount methods that delegate to the cached action
50
+ namespace.define_singleton_method(name) do |**kwargs|
51
+ axn = descriptor_ref.mounted_axn_for(target:)
52
+ axn.call(**kwargs)
53
+ end
54
+
55
+ namespace.define_singleton_method("#{name}!") do |**kwargs|
56
+ axn = descriptor_ref.mounted_axn_for(target:)
57
+ axn.call!(**kwargs)
58
+ end
59
+
60
+ namespace.define_singleton_method("#{name}_async") do |**kwargs|
61
+ axn = descriptor_ref.mounted_axn_for(target:)
62
+ axn.call_async(**kwargs)
63
+ end
64
+
65
+ # Register the action class as a constant in the namespace
66
+ action_class_builder.mount(target, name.to_s)
67
+ end
68
+
69
+ # Check if we should raise an error for method collision
70
+ # Returns true if method exists AND target is not overriding a parent's method (same-class collision)
71
+ def _should_raise_method_collision_error?(target, method_name)
72
+ return false unless target.respond_to?(method_name)
73
+
74
+ # Check if this is an inheritance override by seeing if the parent has the same method
75
+ is_inheritance_override = target.superclass&.respond_to?(method_name)
76
+
77
+ # Only raise error if it's a same-class collision (not inheritance override)
78
+ !is_inheritance_override
79
+ end
80
+ end
81
+ end
82
+ end
83
+ 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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Mountable
5
+ class MountingStrategies
6
+ module EnqueueAll
7
+ include Base
8
+ extend self # rubocop:disable Style/ModuleFunction -- module_function breaks inheritance
9
+
10
+ def default_inherit_mode = :async_only
11
+
12
+ module DSL
13
+ def enqueue_all_via(axn_klass = nil, inherit: MountingStrategies::EnqueueAll.default_inherit_mode, **, &)
14
+ # enqueue_all_via defaults to :async_only - only needs async config for batch enqueuing
15
+ Helpers::Mounter.mount_via_strategy(
16
+ target: self,
17
+ as: :enqueue_all,
18
+ name: "enqueue_all",
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
+ true # Raise or return true
34
+ end
35
+
36
+ mount_method(target:, method_name: "#{name}_async") do |**kwargs|
37
+ axn = descriptor.mounted_axn_for(target: self)
38
+ axn.call_async(**kwargs)
39
+ end
40
+ end
41
+
42
+ def mount_to_namespace(descriptor:, target:)
43
+ super
44
+
45
+ # Add enqueue shortcut to enqueue the *attached-to* axn without
46
+ # the user having to reference __axn_mounted_to__ in their own code
47
+ mounted_axn = descriptor.mounted_axn_for(target:)
48
+ mounted_axn.define_method(:enqueue) do |**kwargs|
49
+ __axn_mounted_to__.call_async(**kwargs)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end