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.
- checksums.yaml +4 -4
- data/.cursor/commands/pr.md +36 -0
- data/.cursor/rules/axn-framework-patterns.mdc +43 -0
- data/.cursor/rules/general-coding-standards.mdc +27 -0
- data/.cursor/rules/spec/testing-patterns.mdc +40 -0
- data/CHANGELOG.md +57 -0
- data/Rakefile +114 -4
- data/docs/.vitepress/config.mjs +19 -10
- data/docs/advanced/conventions.md +3 -3
- data/docs/advanced/mountable.md +476 -0
- data/docs/advanced/profiling.md +351 -0
- data/docs/advanced/rough.md +27 -8
- data/docs/index.md +5 -3
- data/docs/intro/about.md +1 -1
- data/docs/intro/overview.md +6 -6
- data/docs/recipes/formatting-context-for-error-tracking.md +186 -0
- data/docs/recipes/memoization.md +103 -18
- data/docs/recipes/rubocop-integration.md +38 -284
- data/docs/recipes/testing.md +14 -14
- data/docs/recipes/validating-user-input.md +1 -1
- data/docs/reference/async.md +429 -0
- data/docs/reference/axn-result.md +107 -0
- data/docs/reference/class.md +225 -64
- data/docs/reference/configuration.md +366 -34
- data/docs/reference/form-object.md +252 -0
- data/docs/reference/instance.md +14 -29
- data/docs/strategies/client.md +212 -0
- data/docs/strategies/form.md +235 -0
- data/docs/strategies/index.md +21 -21
- data/docs/strategies/transaction.md +1 -1
- data/docs/usage/setup.md +16 -2
- data/docs/usage/steps.md +7 -7
- data/docs/usage/using.md +23 -12
- data/docs/usage/writing.md +191 -12
- data/lib/axn/async/adapters/active_job.rb +74 -0
- data/lib/axn/async/adapters/disabled.rb +41 -0
- data/lib/axn/async/adapters/sidekiq.rb +67 -0
- data/lib/axn/async/adapters.rb +26 -0
- data/lib/axn/async/batch_enqueue/config.rb +38 -0
- data/lib/axn/async/batch_enqueue.rb +99 -0
- data/lib/axn/async/enqueue_all_orchestrator.rb +363 -0
- data/lib/axn/async.rb +178 -0
- data/lib/axn/configuration.rb +113 -0
- data/lib/{action → axn}/context.rb +22 -4
- data/lib/axn/core/automatic_logging.rb +89 -0
- data/lib/axn/core/context/facade.rb +69 -0
- data/lib/{action → axn}/core/context/facade_inspector.rb +32 -5
- data/lib/{action → axn}/core/context/internal.rb +5 -5
- data/lib/{action → axn}/core/contract.rb +111 -73
- data/lib/{action → axn}/core/contract_for_subfields.rb +30 -35
- data/lib/{action → axn}/core/contract_validation.rb +27 -12
- data/lib/axn/core/contract_validation_for_subfields.rb +165 -0
- data/lib/axn/core/default_call.rb +63 -0
- data/lib/axn/core/field_resolvers/extract.rb +32 -0
- data/lib/axn/core/field_resolvers/model.rb +63 -0
- data/lib/axn/core/field_resolvers.rb +24 -0
- data/lib/{action → axn}/core/flow/callbacks.rb +7 -7
- data/lib/{action → axn}/core/flow/exception_execution.rb +9 -13
- data/lib/{action → axn}/core/flow/handlers/base_descriptor.rb +3 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/callback_descriptor.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/descriptors/message_descriptor.rb +23 -11
- data/lib/axn/core/flow/handlers/invoker.rb +47 -0
- data/lib/{action → axn}/core/flow/handlers/matcher.rb +9 -19
- data/lib/{action → axn}/core/flow/handlers/registry.rb +3 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/base_resolver.rb +1 -1
- data/lib/{action → axn}/core/flow/handlers/resolvers/callback_resolver.rb +2 -2
- data/lib/{action → axn}/core/flow/handlers/resolvers/message_resolver.rb +12 -3
- data/lib/axn/core/flow/handlers.rb +20 -0
- data/lib/{action → axn}/core/flow/messages.rb +8 -8
- data/lib/{action → axn}/core/flow.rb +4 -4
- data/lib/{action → axn}/core/hooks.rb +17 -5
- data/lib/axn/core/logging.rb +48 -0
- data/lib/axn/core/memoization.rb +53 -0
- data/lib/{action → axn}/core/nesting_tracking.rb +1 -1
- data/lib/{action → axn}/core/timing.rb +1 -1
- data/lib/axn/core/tracing.rb +90 -0
- data/lib/axn/core/use_strategy.rb +29 -0
- data/lib/{action → axn}/core/validation/fields.rb +26 -2
- data/lib/{action → axn}/core/validation/subfields.rb +14 -12
- data/lib/axn/core/validation/validators/model_validator.rb +36 -0
- data/lib/axn/core/validation/validators/type_validator.rb +80 -0
- data/lib/{action → axn}/core/validation/validators/validate_validator.rb +12 -2
- data/lib/{action → axn}/core.rb +55 -55
- data/lib/{action → axn}/exceptions.rb +12 -2
- data/lib/axn/extras/strategies/client.rb +150 -0
- data/lib/axn/extras/strategies/vernier.rb +121 -0
- data/lib/axn/extras.rb +4 -0
- data/lib/axn/factory.rb +122 -34
- data/lib/axn/form_object.rb +90 -0
- data/lib/axn/internal/logging.rb +30 -0
- data/lib/axn/internal/registry.rb +87 -0
- data/lib/axn/mountable/descriptor.rb +76 -0
- data/lib/axn/mountable/helpers/class_builder.rb +193 -0
- data/lib/axn/mountable/helpers/mounter.rb +33 -0
- data/lib/axn/mountable/helpers/namespace_manager.rb +38 -0
- data/lib/axn/mountable/helpers/validator.rb +112 -0
- data/lib/axn/mountable/inherit_profiles.rb +72 -0
- data/lib/axn/mountable/mounting_strategies/_base.rb +87 -0
- data/lib/axn/mountable/mounting_strategies/axn.rb +48 -0
- data/lib/axn/mountable/mounting_strategies/method.rb +95 -0
- data/lib/axn/mountable/mounting_strategies/step.rb +69 -0
- data/lib/axn/mountable/mounting_strategies.rb +32 -0
- data/lib/axn/mountable.rb +119 -0
- data/lib/axn/rails/engine.rb +51 -0
- data/lib/axn/rails/generators/axn_generator.rb +86 -0
- data/lib/axn/rails/generators/templates/action.rb.erb +17 -0
- data/lib/axn/rails/generators/templates/action_spec.rb.erb +25 -0
- data/lib/{action → axn}/result.rb +32 -13
- data/lib/axn/strategies/form.rb +98 -0
- data/lib/axn/strategies/transaction.rb +26 -0
- data/lib/axn/strategies.rb +20 -0
- data/lib/axn/testing/spec_helpers.rb +6 -8
- data/lib/axn/util/callable.rb +120 -0
- data/lib/axn/util/contract_error_handling.rb +32 -0
- data/lib/axn/util/execution_context.rb +34 -0
- data/lib/axn/util/global_id_serialization.rb +52 -0
- data/lib/axn/util/logging.rb +87 -0
- data/lib/axn/util/memoization.rb +20 -0
- data/lib/axn/version.rb +1 -1
- data/lib/axn.rb +26 -16
- data/lib/rubocop/cop/axn/README.md +23 -23
- data/lib/rubocop/cop/axn/unchecked_result.rb +138 -17
- metadata +106 -64
- data/.rspec +0 -3
- data/.rubocop.yml +0 -76
- data/.tool-versions +0 -1
- data/docs/reference/action-result.md +0 -37
- data/lib/action/attachable/base.rb +0 -43
- data/lib/action/attachable/steps.rb +0 -63
- data/lib/action/attachable/subactions.rb +0 -70
- data/lib/action/attachable.rb +0 -17
- data/lib/action/configuration.rb +0 -55
- data/lib/action/core/automatic_logging.rb +0 -93
- data/lib/action/core/context/facade.rb +0 -48
- data/lib/action/core/flow/handlers/invoker.rb +0 -73
- data/lib/action/core/flow/handlers.rb +0 -20
- data/lib/action/core/logging.rb +0 -37
- data/lib/action/core/tracing.rb +0 -17
- data/lib/action/core/use_strategy.rb +0 -30
- data/lib/action/core/validation/validators/model_validator.rb +0 -34
- data/lib/action/core/validation/validators/type_validator.rb +0 -30
- data/lib/action/enqueueable/via_sidekiq.rb +0 -76
- data/lib/action/enqueueable.rb +0 -13
- data/lib/action/strategies/transaction.rb +0 -19
- data/lib/action/strategies.rb +0 -48
- data/lib/axn/util.rb +0 -24
- data/package.json +0 -10
- data/yarn.lock +0 -1166
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "axn/core/validation/subfields"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Axn
|
|
6
6
|
module Core
|
|
7
7
|
module ContractForSubfields
|
|
8
|
-
|
|
9
|
-
# SubfieldConfig = Data.define(:field, :validations, :default, :preprocess, :sensitive)
|
|
10
|
-
SubfieldConfig = Data.define(:field, :validations, :on)
|
|
8
|
+
SubfieldConfig = Data.define(:field, :validations, :on, :sensitive, :preprocess, :default)
|
|
11
9
|
|
|
12
10
|
def self.included(base)
|
|
13
11
|
base.class_eval do
|
|
14
12
|
class_attribute :subfield_configs, default: []
|
|
15
13
|
|
|
16
14
|
extend ClassMethods
|
|
17
|
-
include InstanceMethods
|
|
18
|
-
|
|
19
|
-
before { _validate_subfields_contract! }
|
|
20
15
|
end
|
|
21
16
|
end
|
|
22
17
|
|
|
@@ -27,19 +22,13 @@ module Action
|
|
|
27
22
|
readers: true,
|
|
28
23
|
allow_blank: false,
|
|
29
24
|
allow_nil: false,
|
|
30
|
-
|
|
31
|
-
# TODO: add support for these three options for subfields
|
|
25
|
+
optional: false,
|
|
32
26
|
default: nil,
|
|
33
27
|
preprocess: nil,
|
|
34
28
|
sensitive: false,
|
|
35
29
|
|
|
36
30
|
**validations
|
|
37
31
|
)
|
|
38
|
-
# TODO: add support for these three options for subfields
|
|
39
|
-
raise ArgumentError, "expects does not support :default key when also given :on" if default.present?
|
|
40
|
-
raise ArgumentError, "expects does not support :preprocess key when also given :on" if preprocess.present?
|
|
41
|
-
raise ArgumentError, "expects does not support :sensitive key when also given :on" if sensitive.present?
|
|
42
|
-
|
|
43
32
|
unless internal_field_configs.map(&:field).include?(on) || subfield_configs.map(&:field).include?(on)
|
|
44
33
|
raise ArgumentError,
|
|
45
34
|
"expects called with `on: #{on}`, but no such method exists (are you sure you've declared `expects :#{on}`?)"
|
|
@@ -47,10 +36,10 @@ module Action
|
|
|
47
36
|
|
|
48
37
|
raise ArgumentError, "expects does not support expecting fields on nested attributes (i.e. `on` cannot contain periods)" if on.to_s.include?(".")
|
|
49
38
|
|
|
50
|
-
|
|
51
|
-
|
|
39
|
+
_parse_subfield_configs(*fields, on:, readers:, allow_blank:, allow_nil:, optional:, preprocess:, sensitive:, default:,
|
|
40
|
+
**validations).tap do |configs|
|
|
52
41
|
duplicated = subfield_configs.map(&:field) & configs.map(&:field)
|
|
53
|
-
raise
|
|
42
|
+
raise Axn::DuplicateFieldError, "Duplicate field(s) declared: #{duplicated.join(', ')}" if duplicated.any?
|
|
54
43
|
|
|
55
44
|
# NOTE: avoid <<, which would update value for parents and children
|
|
56
45
|
self.subfield_configs += configs
|
|
@@ -65,14 +54,18 @@ module Action
|
|
|
65
54
|
readers:,
|
|
66
55
|
allow_blank: false,
|
|
67
56
|
allow_nil: false,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
57
|
+
optional: false,
|
|
58
|
+
preprocess: nil,
|
|
59
|
+
sensitive: false,
|
|
60
|
+
default: nil,
|
|
71
61
|
**validations
|
|
72
62
|
)
|
|
63
|
+
# Handle optional: true by setting allow_blank: true
|
|
64
|
+
allow_blank ||= optional
|
|
65
|
+
|
|
73
66
|
_parse_field_validations(*fields, allow_nil:, allow_blank:, **validations).map do |field, parsed_validations|
|
|
74
67
|
_define_subfield_reader(field, on:, validations: parsed_validations) if readers
|
|
75
|
-
SubfieldConfig.new(field:, validations: parsed_validations, on:)
|
|
68
|
+
SubfieldConfig.new(field:, validations: parsed_validations, on:, sensitive:, preprocess:, default:)
|
|
76
69
|
end
|
|
77
70
|
end
|
|
78
71
|
|
|
@@ -82,24 +75,26 @@ module Action
|
|
|
82
75
|
|
|
83
76
|
raise ArgumentError, "expects does not support duplicate sub-keys (i.e. `#{field}` is already defined)" if method_defined?(field)
|
|
84
77
|
|
|
85
|
-
define_memoized_reader_method(field) do
|
|
86
|
-
|
|
78
|
+
Axn::Util::Memoization.define_memoized_reader_method(self, field) do
|
|
79
|
+
Axn::Core::FieldResolvers.resolve(type: :extract, field:, provided_data: public_send(on))
|
|
87
80
|
end
|
|
88
81
|
|
|
89
|
-
|
|
82
|
+
_define_subfield_model_reader(field, validations[:model], on:) if validations.key?(:model)
|
|
90
83
|
end
|
|
91
|
-
end
|
|
92
84
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
85
|
+
def _define_subfield_model_reader(field, options, on:)
|
|
86
|
+
# Apply the same syntactic sugar processing as the main contract system
|
|
87
|
+
processed_options = Axn::Validators::ModelValidator.apply_syntactic_sugar(options, [field])
|
|
88
|
+
|
|
89
|
+
Axn::Util::Memoization.define_memoized_reader_method(self, field) do
|
|
90
|
+
# Create a data source that contains the subfield data for the resolver
|
|
91
|
+
subfield_data = public_send(on)
|
|
96
92
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
field
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
exception_klass: Action::InboundValidationError,
|
|
93
|
+
Axn::Core::FieldResolvers.resolve(
|
|
94
|
+
type: :model,
|
|
95
|
+
field:,
|
|
96
|
+
options: processed_options,
|
|
97
|
+
provided_data: subfield_data,
|
|
103
98
|
)
|
|
104
99
|
end
|
|
105
100
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Axn
|
|
4
4
|
module Core
|
|
5
5
|
module ContractValidation
|
|
6
6
|
private
|
|
@@ -10,11 +10,16 @@ module Action
|
|
|
10
10
|
next unless config.preprocess
|
|
11
11
|
|
|
12
12
|
initial_value = @__context.provided_data[config.field]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
21
|
+
|
|
22
|
+
_apply_inbound_preprocessing_for_subfields!
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def _validate_contract!(direction)
|
|
@@ -25,9 +30,12 @@ module Action
|
|
|
25
30
|
hash[config.field] = config.validations
|
|
26
31
|
end
|
|
27
32
|
context = direction == :inbound ? internal_context : result
|
|
28
|
-
exception_klass = direction == :inbound ?
|
|
33
|
+
exception_klass = direction == :inbound ? Axn::InboundValidationError : Axn::OutboundValidationError
|
|
29
34
|
|
|
30
35
|
Validation::Fields.validate!(validations:, context:, exception_klass:)
|
|
36
|
+
|
|
37
|
+
# Validate subfields for inbound direction
|
|
38
|
+
_validate_subfields_contract! if direction == :inbound
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def _apply_defaults!(direction)
|
|
@@ -37,9 +45,9 @@ module Action
|
|
|
37
45
|
# For outbound defaults, first copy values from provided_data for fields that are both expected and exposed
|
|
38
46
|
external_field_configs.each do |config|
|
|
39
47
|
field = config.field
|
|
40
|
-
next if @__context.exposed_data
|
|
48
|
+
next if @__context.exposed_data.key?(field) # Already has a value
|
|
41
49
|
|
|
42
|
-
@__context.exposed_data[field] = @__context.provided_data[field] if @__context.provided_data
|
|
50
|
+
@__context.exposed_data[field] = @__context.provided_data[field] if @__context.provided_data.key?(field)
|
|
43
51
|
end
|
|
44
52
|
end
|
|
45
53
|
|
|
@@ -50,12 +58,19 @@ module Action
|
|
|
50
58
|
|
|
51
59
|
defaults_mapping.each do |field, default_value_getter|
|
|
52
60
|
data_hash = direction == :inbound ? @__context.provided_data : @__context.exposed_data
|
|
53
|
-
next if data_hash[field].
|
|
61
|
+
next if data_hash.key?(field) && !data_hash[field].nil?
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
58
70
|
end
|
|
71
|
+
|
|
72
|
+
# Apply subfield defaults for inbound direction
|
|
73
|
+
_apply_defaults_for_subfields! if direction == :inbound
|
|
59
74
|
end
|
|
60
75
|
end
|
|
61
76
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Core
|
|
5
|
+
module ContractValidationForSubfields
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Applies preprocessing to all subfield configurations
|
|
9
|
+
def _apply_inbound_preprocessing_for_subfields!
|
|
10
|
+
_for_each_relevant_subfield_config(:preprocess) do |config, parent_field, subfield, parent_value|
|
|
11
|
+
current_subfield_value = Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_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
|
|
19
|
+
_update_subfield_value(parent_field, subfield, preprocessed_value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Applies default values to all subfield configurations
|
|
24
|
+
def _apply_defaults_for_subfields!
|
|
25
|
+
_for_each_relevant_subfield_config(:default) do |config, parent_field, subfield, parent_value|
|
|
26
|
+
next if parent_value && !Axn::Core::FieldResolvers.resolve(type: :extract, field: subfield, provided_data: parent_value).nil?
|
|
27
|
+
|
|
28
|
+
@__context.provided_data[parent_field] = {} if parent_value.nil?
|
|
29
|
+
|
|
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
|
|
37
|
+
_update_subfield_value(parent_field, subfield, default_value)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Validates all subfield configurations against their defined validations
|
|
42
|
+
def _validate_subfields_contract!
|
|
43
|
+
_for_each_relevant_subfield_config do |config, parent_field, subfield, _parent_value|
|
|
44
|
+
Validation::Subfields.validate!(
|
|
45
|
+
field: subfield,
|
|
46
|
+
validations: config.validations,
|
|
47
|
+
source: public_send(parent_field),
|
|
48
|
+
exception_klass: Axn::InboundValidationError,
|
|
49
|
+
action: self,
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# Here down - helpers for the above methods
|
|
56
|
+
#
|
|
57
|
+
|
|
58
|
+
# Iterates over subfield configurations, optionally filtered by attribute, yielding to a block
|
|
59
|
+
def _for_each_relevant_subfield_config(attribute = nil)
|
|
60
|
+
subfield_configs.each do |config|
|
|
61
|
+
next if attribute && !config.public_send(attribute)
|
|
62
|
+
|
|
63
|
+
parent_field = config.on
|
|
64
|
+
subfield = config.field
|
|
65
|
+
parent_value = @__context.provided_data[parent_field]
|
|
66
|
+
|
|
67
|
+
yield(config, parent_field, subfield, parent_value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Updates a subfield value, handling nested paths, hash objects, and method-based setters
|
|
72
|
+
def _update_subfield_value(parent_field, subfield, new_value)
|
|
73
|
+
parent_value = @__context.provided_data[parent_field]
|
|
74
|
+
|
|
75
|
+
if _is_nested_subfield?(subfield)
|
|
76
|
+
_update_nested_subfield_value(parent_field, subfield, new_value)
|
|
77
|
+
elsif parent_value.is_a?(Hash)
|
|
78
|
+
_update_simple_hash_subfield(parent_field, subfield, new_value)
|
|
79
|
+
elsif parent_value.respond_to?("#{subfield}=")
|
|
80
|
+
_update_object_subfield(parent_value, subfield, new_value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Checks if a subfield path contains nested access (e.g., "user.profile.name")
|
|
85
|
+
def _is_nested_subfield?(subfield) = subfield.to_s.include?(".")
|
|
86
|
+
|
|
87
|
+
# Parses a subfield path into an array of parts
|
|
88
|
+
def _parse_subfield_path(subfield) = subfield.to_s.split(".")
|
|
89
|
+
|
|
90
|
+
# Updates a simple hash subfield value
|
|
91
|
+
def _update_simple_hash_subfield(parent_field, subfield, new_value)
|
|
92
|
+
parent_value = @__context.provided_data[parent_field].dup
|
|
93
|
+
parent_value[subfield] = new_value
|
|
94
|
+
@__context.provided_data[parent_field] = parent_value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Updates an object subfield using method assignment
|
|
98
|
+
def _update_object_subfield(parent_value, subfield, new_value)
|
|
99
|
+
parent_value.public_send("#{subfield}=", new_value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Updates a nested subfield value by navigating the path and creating intermediate hashes
|
|
103
|
+
def _update_nested_subfield_value(parent_field, subfield, new_value)
|
|
104
|
+
parent_value = @__context.provided_data[parent_field]
|
|
105
|
+
path_parts = _parse_subfield_path(subfield)
|
|
106
|
+
|
|
107
|
+
target_parent = _navigate_to_parent(parent_value, path_parts)
|
|
108
|
+
target_parent[path_parts.last.to_sym] = new_value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Navigates to the parent of the target field, creating intermediate hashes as needed
|
|
112
|
+
def _navigate_to_parent(parent_value, path_parts)
|
|
113
|
+
path_parts[0..-2].reduce(parent_value) do |current, part|
|
|
114
|
+
current[part.to_sym] || current[part] || (current[part.to_sym] = {})
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Checks if a subfield exists in the parent value, handling both hash and object types
|
|
119
|
+
def _subfield_exists?(parent_value, subfield)
|
|
120
|
+
if parent_value.is_a?(Hash)
|
|
121
|
+
_hash_subfield_exists?(parent_value, subfield)
|
|
122
|
+
elsif parent_value.respond_to?(subfield)
|
|
123
|
+
_object_subfield_exists?(parent_value, subfield)
|
|
124
|
+
else
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Checks if a subfield exists in a hash, handling both simple and nested paths
|
|
130
|
+
def _hash_subfield_exists?(parent_value, subfield)
|
|
131
|
+
if _is_nested_subfield?(subfield)
|
|
132
|
+
_nested_hash_subfield_exists?(parent_value, subfield)
|
|
133
|
+
else
|
|
134
|
+
_simple_hash_subfield_exists?(parent_value, subfield)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Checks if a simple (non-nested) hash subfield exists
|
|
139
|
+
def _simple_hash_subfield_exists?(parent_value, subfield)
|
|
140
|
+
parent_value.key?(subfield.to_sym) || parent_value.key?(subfield)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Checks if a nested hash subfield exists by navigating the path
|
|
144
|
+
def _nested_hash_subfield_exists?(parent_value, subfield)
|
|
145
|
+
path_parts = _parse_subfield_path(subfield)
|
|
146
|
+
current = parent_value
|
|
147
|
+
|
|
148
|
+
path_parts.each do |part|
|
|
149
|
+
return false unless current.is_a?(Hash)
|
|
150
|
+
return false unless current.key?(part.to_sym) || current.key?(part)
|
|
151
|
+
|
|
152
|
+
current = current[part.to_sym] || current[part]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Checks if an object subfield exists (not nil)
|
|
159
|
+
# This ensures we apply defaults for nil values on objects
|
|
160
|
+
def _object_subfield_exists?(parent_value, subfield)
|
|
161
|
+
!parent_value.public_send(subfield).nil?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module Core
|
|
7
|
+
module FieldResolvers
|
|
8
|
+
class Extract
|
|
9
|
+
def initialize(field:, provided_data:, options: {})
|
|
10
|
+
@field = field
|
|
11
|
+
@options = options
|
|
12
|
+
@provided_data = provided_data
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
# Handle method calls if the source responds to the field
|
|
17
|
+
return provided_data.public_send(field) if provided_data.respond_to?(field)
|
|
18
|
+
|
|
19
|
+
# For hash-like objects, use digging with indifferent access
|
|
20
|
+
raise "Unclear how to extract #{field} from #{provided_data.inspect}" unless provided_data.respond_to?(:dig)
|
|
21
|
+
|
|
22
|
+
base = provided_data.respond_to?(:with_indifferent_access) ? provided_data.with_indifferent_access : provided_data
|
|
23
|
+
base.dig(*field.to_s.split("."))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :field, :options, :provided_data
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Core
|
|
5
|
+
module FieldResolvers
|
|
6
|
+
class Model
|
|
7
|
+
def initialize(field:, options:, provided_data:)
|
|
8
|
+
@field = field
|
|
9
|
+
@options = options
|
|
10
|
+
@provided_data = provided_data
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
provided_value.presence || derive_value
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :field, :options, :provided_data
|
|
20
|
+
|
|
21
|
+
def provided_value
|
|
22
|
+
@provided_value ||= provided_data[field]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def derive_value
|
|
26
|
+
return nil if id_value.blank?
|
|
27
|
+
|
|
28
|
+
# Handle different finder types
|
|
29
|
+
if finder.is_a?(Method)
|
|
30
|
+
# Method object - call it directly
|
|
31
|
+
finder.call(id_value)
|
|
32
|
+
elsif klass.respond_to?(finder)
|
|
33
|
+
# Symbol/string method name on the klass
|
|
34
|
+
klass.public_send(finder, id_value)
|
|
35
|
+
else
|
|
36
|
+
raise "Unknown finder: #{finder}"
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
# Log the exception but don't re-raise
|
|
40
|
+
finder_name = finder.is_a?(Method) ? finder.name : finder
|
|
41
|
+
Axn::Internal::Logging.piping_error("finding #{field} with #{finder_name}", exception: e)
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def klass
|
|
46
|
+
@klass ||= options[:klass]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finder
|
|
50
|
+
@finder ||= options[:finder]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def id_field
|
|
54
|
+
@id_field ||= :"#{field}_id"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def id_value
|
|
58
|
+
@id_value ||= provided_data[id_field]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axn/core/field_resolvers/model"
|
|
4
|
+
require "axn/core/field_resolvers/extract"
|
|
5
|
+
|
|
6
|
+
module Axn
|
|
7
|
+
module Core
|
|
8
|
+
module FieldResolvers
|
|
9
|
+
# Registry for field resolvers
|
|
10
|
+
# This allows us to easily add new field types in the future
|
|
11
|
+
RESOLVERS = {
|
|
12
|
+
model: FieldResolvers::Model,
|
|
13
|
+
extract: FieldResolvers::Extract,
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.resolve(type:, field:, provided_data:, options: {})
|
|
17
|
+
resolver_class = RESOLVERS[type]
|
|
18
|
+
raise ArgumentError, "Unknown field resolver type: #{type}" unless resolver_class
|
|
19
|
+
|
|
20
|
+
resolver_class.new(field:, options:, provided_data:).call
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
3
|
+
require "axn/core/flow/handlers"
|
|
4
|
+
require "axn/core/flow/handlers/resolvers/callback_resolver"
|
|
5
5
|
|
|
6
|
-
module
|
|
6
|
+
module Axn
|
|
7
7
|
module Core
|
|
8
8
|
module Flow
|
|
9
9
|
module Callbacks
|
|
10
10
|
def self.included(base)
|
|
11
11
|
base.class_eval do
|
|
12
|
-
class_attribute :_callbacks_registry, default:
|
|
12
|
+
class_attribute :_callbacks_registry, default: Axn::Core::Flow::Handlers::Registry.empty
|
|
13
13
|
|
|
14
14
|
extend ClassMethods
|
|
15
15
|
end
|
|
@@ -18,7 +18,7 @@ module Action
|
|
|
18
18
|
module ClassMethods
|
|
19
19
|
# Internal dispatcher
|
|
20
20
|
def _dispatch_callbacks(event_type, action:, exception: nil)
|
|
21
|
-
resolver =
|
|
21
|
+
resolver = Axn::Core::Flow::Handlers::Resolvers::CallbackResolver.new(
|
|
22
22
|
_callbacks_registry,
|
|
23
23
|
event_type,
|
|
24
24
|
action:,
|
|
@@ -48,12 +48,12 @@ module Action
|
|
|
48
48
|
raise ArgumentError, "on_#{event_type} must be called with a block or symbol" unless block || handler
|
|
49
49
|
|
|
50
50
|
# If handler is already a descriptor, use it directly
|
|
51
|
-
entry = if handler.is_a?(
|
|
51
|
+
entry = if handler.is_a?(Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor)
|
|
52
52
|
raise ArgumentError, "Cannot pass additional configuration with prebuilt descriptor" if kwargs.any? || block
|
|
53
53
|
|
|
54
54
|
handler
|
|
55
55
|
else
|
|
56
|
-
|
|
56
|
+
Axn::Core::Flow::Handlers::Descriptors::CallbackDescriptor.build(
|
|
57
57
|
handler: handler || block,
|
|
58
58
|
**kwargs,
|
|
59
59
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Axn
|
|
4
4
|
module Core
|
|
5
5
|
module Flow
|
|
6
6
|
module ExceptionExecution
|
|
@@ -10,14 +10,15 @@ module Action
|
|
|
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
|
|
16
|
-
|
|
17
|
+
Axn.config.on_exception(exception, action: self, context: context_for_logging)
|
|
17
18
|
rescue StandardError => e
|
|
18
19
|
# No action needed -- downstream #on_exception implementation should ideally log any internal failures, but
|
|
19
20
|
# we don't want exception *handling* failures to cascade and overwrite the original exception.
|
|
20
|
-
Axn::
|
|
21
|
+
Axn::Internal::Logging.piping_error("executing on_exception hooks", action: self, exception: e)
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def _trigger_on_success
|
|
@@ -32,6 +33,10 @@ module Action
|
|
|
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
|
|
|
@@ -39,22 +44,13 @@ module Action
|
|
|
39
44
|
self.class._dispatch_callbacks(:error, action: self, exception: e)
|
|
40
45
|
|
|
41
46
|
# on_failure handlers run ONLY for fail!
|
|
42
|
-
if e.is_a?(
|
|
47
|
+
if e.is_a?(Axn::Failure)
|
|
43
48
|
self.class._dispatch_callbacks(:failure, action: self, exception: e)
|
|
44
49
|
else
|
|
45
50
|
# on_exception handlers run for ONLY for unhandled exceptions.
|
|
46
51
|
_trigger_on_exception(e)
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
|
-
|
|
50
|
-
def try
|
|
51
|
-
yield
|
|
52
|
-
rescue Action::Failure => e
|
|
53
|
-
# NOTE: re-raising so we can still fail! from inside the block
|
|
54
|
-
raise e
|
|
55
|
-
rescue StandardError => e
|
|
56
|
-
_trigger_on_exception(e)
|
|
57
|
-
end
|
|
58
54
|
end
|
|
59
55
|
end
|
|
60
56
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "axn/core/flow/handlers/matcher"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Axn
|
|
6
6
|
module Core
|
|
7
7
|
module Flow
|
|
8
8
|
# "Handlers" doesn't feel like *quite* the right name for this, but basically things in this namespace
|
|
@@ -12,6 +12,7 @@ module Action
|
|
|
12
12
|
def initialize(matcher: nil, handler: nil)
|
|
13
13
|
@matcher = matcher
|
|
14
14
|
@handler = handler
|
|
15
|
+
freeze
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
attr_reader :handler, :matcher
|