interactor_support 1.0.1

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.
@@ -0,0 +1,65 @@
1
+ module InteractorSupport
2
+ module Concerns
3
+ module Transformable
4
+ extend ActiveSupport::Concern
5
+ include InteractorSupport::Core
6
+
7
+ included do
8
+ class << self
9
+ # context_variable first_post: Post.first
10
+ # context_variable user: -> { User.find(user_id) }
11
+ # context_variable items: Item.all
12
+ # context_variable numbers: [1, 2, 3]
13
+ def context_variable(key_values)
14
+ before do
15
+ key_values.each do |key, value|
16
+ context[key] = if value.is_a?(Proc)
17
+ context.instance_exec(&value)
18
+ else
19
+ value
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ # transform :email, :name, with: [:downcase, :strip]
26
+ # transform :url, with: :downcase
27
+ # transform :items, with: :compact
28
+ # transform :items, with: ->(value) { value.compact }
29
+ # transform :email, :url, with: ->(value) { value.downcase.strip }
30
+ # transform :items, with: :compact
31
+ def transform(*keys, with: [])
32
+ before do
33
+ if keys.empty?
34
+ raise ArgumentError, 'transform action requires at least one key.'
35
+ end
36
+
37
+ keys.each do |key|
38
+ if with.is_a?(Proc)
39
+ begin
40
+ context[key] = context.instance_exec(&with)
41
+ rescue => e
42
+ context.fail!(errors: ["#{key} failed to transform: #{e.message}"])
43
+ end
44
+ elsif with.is_a?(Array)
45
+ context.fail!(errors: ["#{key} does not respond to all transforms"]) unless with.all? do |t|
46
+ t.is_a?(Symbol) && context[key].respond_to?(t)
47
+ end
48
+ context[key] = with.inject(context[key]) do |value, method|
49
+ value.send(method)
50
+ end
51
+ elsif with.is_a?(Symbol) && context[key].respond_to?(with)
52
+ context[key] = context[key].send(with)
53
+ elsif with.is_a?(Symbol)
54
+ context.fail!(errors: ["#{key} does not respond to #{with}"])
55
+ else
56
+ raise ArgumentError, 'transform requires `with` to be a symbol or array of symbols.'
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,57 @@
1
+ module InteractorSupport
2
+ module Concerns
3
+ module Updatable
4
+ extend ActiveSupport::Concern
5
+ include InteractorSupport::Core
6
+
7
+ included do
8
+ class << self
9
+ def update(model, attributes: {}, context_key: nil)
10
+ context_key ||= model
11
+
12
+ before do
13
+ record = context[model]
14
+ context.fail!(errors: ["#{model} not found"]) unless record
15
+
16
+ update_data =
17
+ case attributes
18
+ when Hash
19
+ attributes.each_with_object({}) do |(key, value), result|
20
+ case value
21
+ when Hash
22
+ parent = context[key]
23
+ context.fail!(errors: ["#{key} not found"]) unless parent
24
+ result.merge!(value.transform_values { |v| parent[v] })
25
+ when Array
26
+ parent = context[key]
27
+ context.fail!(errors: ["#{key} not found"]) unless parent
28
+ result.merge!(value.index_with { |v| parent[v] })
29
+ when Proc
30
+ begin
31
+ result[key] = context.instance_exec(&value)
32
+ rescue StandardError => e
33
+ context.fail!(errors: [e.message])
34
+ end
35
+ else
36
+ result[key] = context.send(value)
37
+ end
38
+ end
39
+ when Symbol
40
+ data = context[attributes]
41
+ context.fail!(errors: ["#{attributes} not found"]) unless data
42
+ data
43
+ else
44
+ raise ArgumentError, "Invalid attributes: #{attributes}"
45
+ end
46
+
47
+ record.update!(update_data)
48
+
49
+ # Assign the updated record to context
50
+ context[context_key] = record
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,18 @@
1
+ module InteractorSupport
2
+ class Configuration
3
+ attr_accessor :request_object_behavior, :request_object_key_type
4
+
5
+ def initialize
6
+ # Default configuration values.
7
+ # :returns_context - request objects return a context object.
8
+ # :returns_self - request objects return self.
9
+ @request_object_behavior = :returns_context
10
+
11
+ # Default configuration values, only applies when request_object_behavior is :returns_context.
12
+ # :string - request object keys are strings.
13
+ # :symbol - request object keys are symbols.
14
+ # :struct - request object keys are struct objects.
15
+ @request_object_key_type = :symbol
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ module InteractorSupport
2
+ module Core
3
+ class << self
4
+ def included(base)
5
+ # Only include Interactor if it isn’t already present.
6
+ base.include(Interactor) unless base.included_modules.include?(Interactor)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,86 @@
1
+ # app/concerns/interactor_support/request_object.rb
2
+ module InteractorSupport
3
+ module RequestObject
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::Validations::Callbacks
10
+
11
+ def initialize(attributes = {})
12
+ super(attributes)
13
+ raise ActiveModel::ValidationError, self unless valid?
14
+ end
15
+
16
+ def to_context
17
+ key_type = InteractorSupport.configuration.request_object_key_type
18
+ attrs = attributes.each_with_object({}) do |(name, value), hash|
19
+ name = key_type == :string ? name.to_s : name.to_sym
20
+ hash[name] = if value.respond_to?(:to_context)
21
+ value.to_context
22
+ elsif value.is_a?(Array) && value.first.respond_to?(:to_context)
23
+ value.map(&:to_context)
24
+ else
25
+ value
26
+ end
27
+ end
28
+ return Struct.new(*attrs.keys).new(*attrs.values) if key_type == :struct
29
+
30
+ attrs
31
+ end
32
+
33
+ class << self
34
+ def new(*args, **kwargs)
35
+ return super(*args, **kwargs) if InteractorSupport.configuration.request_object_behavior == :returns_self
36
+
37
+ super(*args, **kwargs).to_context
38
+ end
39
+
40
+ # Accepts one or more attribute names along with options.
41
+ #
42
+ # Options:
43
+ # - type: a class to cast the value (often another RequestObject)
44
+ # - array: when true, expects an array; each element is cast.
45
+ # - default: default value for the attribute.
46
+ # - transform: a symbol or an array of symbols that will be applied (if the value responds to them).
47
+ def attribute(*names, type: nil, array: false, default: nil, transform: nil)
48
+ names.each do |name|
49
+ transform_options[name.to_sym] = transform if transform.present?
50
+ super(name, default: default)
51
+ original_writer = instance_method("#{name}=")
52
+ define_method("#{name}=") do |value|
53
+ # Apply transforms immediately if provided.
54
+ if transform
55
+ Array(transform).each do |method|
56
+ if value.respond_to?(method)
57
+ value = value.send(method)
58
+ elsif respond_to?(method)
59
+ value = send(method, value)
60
+ else
61
+ raise ArgumentError, "transform method #{method} not found"
62
+ end
63
+ end
64
+ end
65
+ # Type: only wrap if not already an instance.
66
+ if type
67
+ value = if array
68
+ Array(value).map { |v| v.is_a?(type) ? v : type.new(v) }
69
+ else
70
+ value.is_a?(type) ? value : type.new(value)
71
+ end
72
+ end
73
+ original_writer.bind(self).call(value)
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def transform_options
81
+ @_transform_options ||= {}
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ class BaseInteractorCop < RuboCop::Cop::Base
8
+ def on_class(node)
9
+ check_interactor_usage(node)
10
+ end
11
+
12
+ def on_module(node)
13
+ check_interactor_usage(node)
14
+ end
15
+
16
+ private
17
+
18
+ def extract_included_modules(node)
19
+ node.each_descendant(:send)
20
+ .select { |send_node| send_node.method?(:include) }
21
+ .map { |send_node| [send_node, send_node.first_argument&.const_name] }
22
+ .reject { |_, name| name.nil? }
23
+ end
24
+
25
+ def extract_used_methods(node)
26
+ node.each_descendant(:send).map(&:method_name)
27
+ end
28
+
29
+ def known_module_methods
30
+ {
31
+ 'InteractorSupport::Concerns::Findable' => [:find_by, :find_where],
32
+ 'InteractorSupport::Concerns::Skippable' => [:skip],
33
+ 'InteractorSupport::Concerns::Transactionable' => [:transaction],
34
+ 'InteractorSupport::Concerns::Transformable' => [:context_variable, :transform],
35
+ 'InteractorSupport::Concerns::Updatable' => [:update],
36
+ 'InteractorSupport::Validations' => validations_methods,
37
+ }
38
+ end
39
+
40
+ def module_for_method_name(method_name)
41
+ known_module_methods.each do |mod, methods|
42
+ return mod if methods.include?(method_name)
43
+ end
44
+ nil
45
+ end
46
+
47
+ def validations_methods
48
+ [
49
+ :required,
50
+ :optional,
51
+ :context_accessor,
52
+ :validates_after,
53
+ :validates_before,
54
+ :after_validation,
55
+ :before_validation,
56
+ :validates_acceptance_of,
57
+ :validates_numericality_of,
58
+ :validates_presence_of,
59
+ :validates_length_of,
60
+ :validates_size_of,
61
+ :validates_comparison_of,
62
+ :validates_confirmation_of,
63
+ :validates_absence_of,
64
+ :validates_exclusion_of,
65
+ :validates_format_of,
66
+ :validates_inclusion_of,
67
+ :human_attribute_name,
68
+ :lookup_ancestors,
69
+ :i18n_scope,
70
+ :descendants,
71
+ :reset_callbacks,
72
+ :define_callbacks,
73
+ :set_callback,
74
+ :normalize_callback_params,
75
+ :get_callbacks,
76
+ :set_callbacks,
77
+ :skip_callback,
78
+ :define_model_callbacks,
79
+ :model_name,
80
+ :clear_validators!,
81
+ :validators_on,
82
+ :attribute_method?,
83
+ :validates,
84
+ :validates!,
85
+ :validates_each,
86
+ :validates_with,
87
+ :validate,
88
+ :validators,
89
+ :yaml_tag,
90
+ ]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ class RequireRequiredForInteractorSupport < Cop
6
+ MSG = 'Classes including `InteractorSupport` or `InteractorSupport::Validations` must invoke `required`.'
7
+
8
+ def_node_matcher :calls_required?, <<~PATTERN
9
+ (send nil? :required ...)
10
+ PATTERN
11
+
12
+ def includes_support?(node)
13
+ node.to_s =~ /InteractorSupport/ && (
14
+ node.to_s =~ /\(const nil :InteractorSupport\) :Validations\)/ ||
15
+ (node.to_s =~ /\(const nil :InteractorSupport\)/ && node.to_s !~ /:RequestObject/)
16
+ )
17
+ end
18
+
19
+ def on_class(node)
20
+ return unless includes_support?(node)
21
+
22
+ required_called = calls_required?(node.body) || node.body&.children&.any? do |child|
23
+ calls_required?(child)
24
+ end
25
+ add_offense(node, message: MSG) unless required_called
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+ require_relative 'base_interactor_cop'
5
+
6
+ module RuboCop
7
+ module Cop
8
+ class UnusedIncludedModules < BaseInteractorCop
9
+ MSG_SINGLE = 'Module `%<module>s` is included but its methods are not used in this class.'
10
+ MSG_GROUP = 'Use `%<correct_modules>s` instead.'
11
+
12
+ GROUP_MODULES = {
13
+ 'InteractorSupport' => [
14
+ 'InteractorSupport::Concerns::Findable',
15
+ 'InteractorSupport::Concerns::Skippable',
16
+ 'InteractorSupport::Concerns::Transactionable',
17
+ 'InteractorSupport::Concerns::Transformable',
18
+ 'InteractorSupport::Concerns::Updatable',
19
+ 'InteractorSupport::Validations',
20
+ ],
21
+ 'InteractorSupport::Actions' => [
22
+ 'InteractorSupport::Concerns::Skippable',
23
+ 'InteractorSupport::Concerns::Transactionable',
24
+ 'InteractorSupport::Concerns::Updatable',
25
+ 'InteractorSupport::Concerns::Findable',
26
+ 'InteractorSupport::Concerns::Transformable',
27
+ ],
28
+ }.freeze
29
+
30
+ private
31
+
32
+ def check_interactor_usage(node)
33
+ included_modules = extract_included_modules(node)
34
+ used_methods = extract_used_methods(node)
35
+
36
+ included_modules.each do |include_node, mod|
37
+ next unless mod.start_with?('InteractorSupport')
38
+ next if mod == 'InteractorSupport::RequestObject'
39
+
40
+ expanded_modules = GROUP_MODULES.fetch(mod, [mod])
41
+ unused_modules = expanded_modules.reject do |m|
42
+ known_module_methods[m]&.any? { |meth| used_methods.include?(meth) }
43
+ end
44
+
45
+ next if unused_modules.empty?
46
+
47
+ missing_modules = []
48
+ used_methods.each do |method_name|
49
+ missing_module = module_for_method_name(method_name)
50
+ next if missing_module.nil? || included_modules.any? { |_, mod| mod == missing_module }
51
+
52
+ missing_modules << missing_module unless missing_modules.include?(missing_module)
53
+ end
54
+
55
+ modules = missing_modules.map { |missing_module| "include #{missing_module}" }
56
+ message = if unused_modules.size == 1
57
+ format(MSG_SINGLE, module: unused_modules.first)
58
+ else
59
+ format(MSG_GROUP, correct_modules: modules.join(', '))
60
+ end
61
+
62
+ add_offense(include_node, message: message)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+ require_relative 'base_interactor_cop'
5
+
6
+ module RuboCop
7
+ module Cop
8
+ class UsedUnincludedModules < BaseInteractorCop
9
+ MSG_MISSING_INTERACTOR = '`include Interactor` is required when including `%<module>s`.'
10
+ MSG_MISSING_MODULE = 'Method `%<method>s` is used but `%<module>s` is not included.'
11
+
12
+ private
13
+
14
+ def check_interactor_usage(node)
15
+ included_modules = extract_included_modules(node)
16
+ used_methods = extract_used_methods_with_nodes(node)
17
+
18
+ interactor_included = included_modules.any? { |_, mod| mod == 'Interactor' }
19
+
20
+ included_modules.each do |include_node, mod|
21
+ if mod.start_with?('InteractorSupport') && !interactor_included
22
+ add_offense(include_node, message: format(MSG_MISSING_INTERACTOR, module: mod))
23
+ end
24
+ end
25
+
26
+ return unless interactor_included
27
+
28
+ used_methods.each do |method_name, method_node|
29
+ missing_module = module_for_method_name(method_name)
30
+ next if missing_module.nil? || included_modules.any? { |_, mod| mod == missing_module }
31
+
32
+ # Register offense on the exact method call
33
+ add_offense(
34
+ method_node,
35
+ message: format(MSG_MISSING_MODULE, method: method_name, module: missing_module),
36
+ severity: :info,
37
+ )
38
+ end
39
+ end
40
+
41
+ def extract_used_methods_with_nodes(node)
42
+ node.each_descendant(:send).map { |send_node| [send_node.method_name, send_node] }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,2 @@
1
+ require_relative 'rubocop/cop/require_required_for_interactor_support'
2
+ require_relative 'rubocop/cop/unused_included_modules'
@@ -0,0 +1,116 @@
1
+ require 'active_model'
2
+
3
+ module InteractorSupport
4
+ module Validations
5
+ extend ActiveSupport::Concern
6
+ include InteractorSupport::Core
7
+
8
+ included do
9
+ include ActiveModel::Validations
10
+ include ActiveModel::Validations::Callbacks
11
+ end
12
+
13
+ class_methods do
14
+ def required(*keys)
15
+ apply_validations(keys, required: true)
16
+ end
17
+
18
+ def optional(*keys)
19
+ apply_validations(keys, required: false)
20
+ end
21
+
22
+ def validates_after(*keys, **validations)
23
+ after do
24
+ keys.each do |key|
25
+ apply_custom_validations(key, validations)
26
+ end
27
+ end
28
+ end
29
+
30
+ def validates_before(*keys, **validations)
31
+ before do
32
+ if validations[:persisted]
33
+ context.fail!(errors: ['persisted validation is only available for after validations'])
34
+ end
35
+
36
+ keys.each do |key|
37
+ apply_custom_validations(key, validations)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def apply_validations(keys, required:)
45
+ keys.each do |key|
46
+ if key.is_a?(Hash)
47
+ key.each do |attribute, validation_options|
48
+ attr_accessor(attribute)
49
+
50
+ define_context_methods(attribute)
51
+ if required
52
+ validates(attribute, validation_options)
53
+ else
54
+ validates(attribute, validation_options.merge(allow_nil: true))
55
+ end
56
+ end
57
+ else
58
+ attr_accessor(key)
59
+
60
+ define_context_methods(key)
61
+ validates(key, presence: true) if required
62
+ end
63
+ end
64
+ # Ensure validations run before execution
65
+ before do
66
+ context.fail!(errors: errors.full_messages) unless valid?
67
+ end
68
+ end
69
+
70
+ def define_context_methods(key)
71
+ define_method(key) { context[key] }
72
+ define_method("#{key}=") { |value| context[key] = value }
73
+ end
74
+ end
75
+
76
+ def apply_custom_validations(key, validations)
77
+ validation_for_presence(key) if validations[:presence]
78
+ validation_for_inclusion(key, validations[:inclusion]) if validations[:inclusion]
79
+ validation_for_persistence(key) if validations[:persisted]
80
+ validation_for_type(key, validations[:type]) if validations[:type]
81
+ end
82
+
83
+ def validation_for_type(key, type)
84
+ context.fail!(errors: ["#{key} was not of type #{type}"]) unless context[key].is_a?(type)
85
+ end
86
+
87
+ def validation_for_inclusion(key, inclusion)
88
+ unless inclusion.is_a?(Hash) && inclusion[:in].is_a?(Enumerable)
89
+ raise ArgumentError, 'inclusion validation requires an :in key with an array or range'
90
+ end
91
+
92
+ context.fail!(errors: ["#{key} was not in the specified inclusion"]) unless inclusion[:in].include?(context[key])
93
+ rescue ArgumentError => e
94
+ context.fail!(errors: [e.message])
95
+ end
96
+
97
+ def validation_for_presence(key)
98
+ context.fail!(errors: ["#{key} does not exist"]) unless context[key].present?
99
+ end
100
+
101
+ def validation_for_persistence(key)
102
+ validation_for_presence(key)
103
+ unless context[key].is_a?(ApplicationRecord)
104
+ context.fail!(
105
+ errors: [
106
+ "#{key} is not an ApplicationRecord, which is required for persisted validation",
107
+ ],
108
+ )
109
+ end
110
+
111
+ context.fail!(
112
+ errors: ["#{key} was not persisted"] + context[key].errors.full_messages,
113
+ ) unless context[key].persisted?
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InteractorSupport
4
+ VERSION = '1.0.1'
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'interactor'
4
+ require 'logger'
5
+ require 'active_support/concern'
6
+ require_relative 'interactor_support/core'
7
+ require_relative 'interactor_support/version'
8
+ require_relative 'interactor_support/actions'
9
+ require_relative 'interactor_support/validations'
10
+ require_relative 'interactor_support/request_object'
11
+ require_relative 'interactor_support/configuration'
12
+
13
+ Dir[File.join(__dir__, 'interactor_support/concerns/*.rb')].sort.each { |file| require file }
14
+
15
+ module InteractorSupport
16
+ extend ActiveSupport::Concern
17
+
18
+ class << self
19
+ def configure
20
+ self.configuration ||= Configuration.new
21
+ yield(configuration) if block_given?
22
+ end
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+ end
28
+
29
+ included do
30
+ include InteractorSupport::Actions
31
+ include InteractorSupport::Validations
32
+ end
33
+ end
@@ -0,0 +1,4 @@
1
+ module InteractorSupport
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end