active_interaction 4.0.5 → 5.0.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -6
  3. data/README.md +67 -32
  4. data/lib/active_interaction/array_input.rb +77 -0
  5. data/lib/active_interaction/base.rb +14 -98
  6. data/lib/active_interaction/concerns/active_recordable.rb +3 -3
  7. data/lib/active_interaction/concerns/missable.rb +2 -2
  8. data/lib/active_interaction/errors.rb +6 -88
  9. data/lib/active_interaction/exceptions.rb +47 -0
  10. data/lib/active_interaction/filter/column.rb +59 -0
  11. data/lib/active_interaction/filter/error.rb +40 -0
  12. data/lib/active_interaction/filter.rb +44 -53
  13. data/lib/active_interaction/filters/abstract_date_time_filter.rb +9 -6
  14. data/lib/active_interaction/filters/abstract_numeric_filter.rb +7 -3
  15. data/lib/active_interaction/filters/array_filter.rb +36 -10
  16. data/lib/active_interaction/filters/boolean_filter.rb +4 -3
  17. data/lib/active_interaction/filters/date_filter.rb +1 -1
  18. data/lib/active_interaction/filters/date_time_filter.rb +1 -1
  19. data/lib/active_interaction/filters/decimal_filter.rb +1 -1
  20. data/lib/active_interaction/filters/float_filter.rb +1 -1
  21. data/lib/active_interaction/filters/hash_filter.rb +23 -15
  22. data/lib/active_interaction/filters/integer_filter.rb +1 -1
  23. data/lib/active_interaction/filters/interface_filter.rb +12 -12
  24. data/lib/active_interaction/filters/object_filter.rb +9 -3
  25. data/lib/active_interaction/filters/record_filter.rb +21 -11
  26. data/lib/active_interaction/filters/string_filter.rb +1 -1
  27. data/lib/active_interaction/filters/symbol_filter.rb +1 -1
  28. data/lib/active_interaction/filters/time_filter.rb +4 -4
  29. data/lib/active_interaction/hash_input.rb +43 -0
  30. data/lib/active_interaction/input.rb +23 -0
  31. data/lib/active_interaction/inputs.rb +157 -46
  32. data/lib/active_interaction/locale/en.yml +0 -1
  33. data/lib/active_interaction/locale/fr.yml +0 -1
  34. data/lib/active_interaction/locale/it.yml +0 -1
  35. data/lib/active_interaction/locale/ja.yml +0 -1
  36. data/lib/active_interaction/locale/pt-BR.yml +0 -1
  37. data/lib/active_interaction/modules/validation.rb +6 -17
  38. data/lib/active_interaction/version.rb +1 -1
  39. data/lib/active_interaction.rb +43 -36
  40. data/spec/active_interaction/array_input_spec.rb +166 -0
  41. data/spec/active_interaction/base_spec.rb +15 -240
  42. data/spec/active_interaction/concerns/active_modelable_spec.rb +3 -3
  43. data/spec/active_interaction/concerns/active_recordable_spec.rb +7 -7
  44. data/spec/active_interaction/concerns/hashable_spec.rb +8 -8
  45. data/spec/active_interaction/concerns/missable_spec.rb +9 -9
  46. data/spec/active_interaction/concerns/runnable_spec.rb +34 -32
  47. data/spec/active_interaction/errors_spec.rb +60 -43
  48. data/spec/active_interaction/{filter_column_spec.rb → filter/column_spec.rb} +3 -10
  49. data/spec/active_interaction/filter_spec.rb +6 -6
  50. data/spec/active_interaction/filters/abstract_date_time_filter_spec.rb +2 -2
  51. data/spec/active_interaction/filters/abstract_numeric_filter_spec.rb +2 -2
  52. data/spec/active_interaction/filters/array_filter_spec.rb +99 -24
  53. data/spec/active_interaction/filters/boolean_filter_spec.rb +12 -11
  54. data/spec/active_interaction/filters/date_filter_spec.rb +32 -27
  55. data/spec/active_interaction/filters/date_time_filter_spec.rb +34 -29
  56. data/spec/active_interaction/filters/decimal_filter_spec.rb +20 -18
  57. data/spec/active_interaction/filters/file_filter_spec.rb +7 -7
  58. data/spec/active_interaction/filters/float_filter_spec.rb +19 -17
  59. data/spec/active_interaction/filters/hash_filter_spec.rb +16 -18
  60. data/spec/active_interaction/filters/integer_filter_spec.rb +24 -22
  61. data/spec/active_interaction/filters/interface_filter_spec.rb +105 -82
  62. data/spec/active_interaction/filters/object_filter_spec.rb +52 -36
  63. data/spec/active_interaction/filters/record_filter_spec.rb +61 -39
  64. data/spec/active_interaction/filters/string_filter_spec.rb +7 -7
  65. data/spec/active_interaction/filters/symbol_filter_spec.rb +6 -6
  66. data/spec/active_interaction/filters/time_filter_spec.rb +57 -34
  67. data/spec/active_interaction/hash_input_spec.rb +58 -0
  68. data/spec/active_interaction/i18n_spec.rb +22 -17
  69. data/spec/active_interaction/inputs_spec.rb +167 -23
  70. data/spec/active_interaction/integration/array_interaction_spec.rb +3 -7
  71. data/spec/active_interaction/modules/validation_spec.rb +8 -31
  72. data/spec/spec_helper.rb +8 -0
  73. data/spec/support/concerns.rb +2 -2
  74. data/spec/support/filters.rb +27 -51
  75. data/spec/support/interactions.rb +4 -4
  76. metadata +45 -95
  77. data/lib/active_interaction/filter_column.rb +0 -57
@@ -8,6 +8,8 @@ module ActiveInteraction
8
8
  #
9
9
  # @!macro filter_method_params
10
10
  # @param block [Proc] filter method to apply to each element
11
+ # @option options [Boolean] :index_errors (ActiveRecord.index_nested_attribute_errors) returns errors with an
12
+ # index
11
13
  #
12
14
  # @example
13
15
  # array :ids
@@ -36,8 +38,37 @@ module ActiveInteraction
36
38
 
37
39
  register :array
38
40
 
41
+ def process(value, context)
42
+ input = super
43
+
44
+ return ArrayInput.new(self, value: input.value, error: input.errors.first) if input.errors.any?
45
+ return ArrayInput.new(self, value: default(context), error: input.errors.first) if input.value.nil?
46
+
47
+ value = input.value
48
+ error = nil
49
+ children = []
50
+
51
+ unless filters.empty?
52
+ value.each do |item|
53
+ children.push(filters[:'0'].process(item, context))
54
+ end
55
+ end
56
+
57
+ ArrayInput.new(self, value: value, error: error, children: children, index_errors: index_errors?)
58
+ end
59
+
39
60
  private
40
61
 
62
+ def index_errors?
63
+ default =
64
+ if ::ActiveRecord.respond_to?(:index_nested_attribute_errors)
65
+ ::ActiveRecord.index_nested_attribute_errors # Moved to here in Rails 7.0
66
+ else
67
+ ::ActiveRecord::Base.index_nested_attribute_errors
68
+ end
69
+ options.fetch(:index_errors, default)
70
+ end
71
+
41
72
  def klasses
42
73
  %w[
43
74
  ActiveRecord::Relation
@@ -55,16 +86,13 @@ module ActiveInteraction
55
86
  false
56
87
  end
57
88
 
58
- def adjust_output(value, context)
59
- return value if filters.empty?
60
-
61
- filter = filters.values.first
62
- value.map { |e| filter.clean(e, context) }
89
+ def adjust_output(value, _context)
90
+ value.to_a
63
91
  end
64
92
 
65
93
  def convert(value)
66
94
  if value.respond_to?(:to_ary)
67
- value.to_ary
95
+ [value.to_ary, nil]
68
96
  else
69
97
  super
70
98
  end
@@ -91,7 +119,7 @@ module ActiveInteraction
91
119
 
92
120
  filters[filters.size.to_s.to_sym] = filter
93
121
 
94
- validate!(filter, names)
122
+ validate!(names)
95
123
  end
96
124
  end
97
125
  # rubocop:enable Style/MissingRespondToMissing
@@ -100,13 +128,11 @@ module ActiveInteraction
100
128
  # @param names [Array<Symbol>]
101
129
  #
102
130
  # @raise [InvalidFilterError]
103
- def validate!(filter, names)
131
+ def validate!(names)
104
132
  raise InvalidFilterError, 'multiple filters in array block' if filters.size > 1
105
133
 
106
134
  raise InvalidFilterError, 'attribute names in array block' unless names.empty?
107
135
 
108
- raise InvalidDefaultError, 'default values in array block' if filter.default?
109
-
110
136
  nil
111
137
  end
112
138
  end
@@ -6,7 +6,8 @@ module ActiveInteraction
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are Booleans. The strings `"1"`, `"true"`, and `"on"`
8
8
  # (case-insensitive) are converted to `true` while the strings `"0"`,
9
- # `"false"`, and `"off"` are converted to `false`.
9
+ # `"false"`, and `"off"` are converted to `false`. Blank strings are
10
+ # treated as a `nil` value.
10
11
  #
11
12
  # @!macro filter_method_params
12
13
  #
@@ -38,9 +39,9 @@ module ActiveInteraction
38
39
 
39
40
  case value
40
41
  when /\A(?:0|false|off)\z/i
41
- false
42
+ [false, nil]
42
43
  when /\A(?:1|true|on)\z/i
43
- true
44
+ [true, nil]
44
45
  else
45
46
  super
46
47
  end
@@ -6,7 +6,7 @@ module ActiveInteraction
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are Dates. String values are processed using `parse`
8
8
  # unless the format option is given, in which case they will be
9
- # processed with `strptime`.
9
+ # processed with `strptime`. Blank strings are treated as a `nil` value.
10
10
  #
11
11
  # @!macro filter_method_params
12
12
  # @option options [String] :format parse strings using this format string
@@ -6,7 +6,7 @@ module ActiveInteraction
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are DateTimes. String values are processed using
8
8
  # `parse` unless the format option is given, in which case they will be
9
- # processed with `strptime`.
9
+ # processed with `strptime`. Blank strings are treated as a `nil` value.
10
10
  #
11
11
  # @!macro filter_method_params
12
12
  # @option options [String] :format parse strings using this format string
@@ -7,7 +7,7 @@ module ActiveInteraction
7
7
  # @!method self.decimal(*attributes, options = {})
8
8
  # Creates accessors for the attributes and ensures that values passed to
9
9
  # the attributes are BigDecimals. Numerics and String values are
10
- # converted into BigDecimals.
10
+ # converted into BigDecimals. Blank strings are treated as a `nil` value.
11
11
  #
12
12
  # @!macro filter_method_params
13
13
  #
@@ -5,7 +5,7 @@ module ActiveInteraction
5
5
  # @!method self.float(*attributes, options = {})
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are Floats. Integer and String values are converted
8
- # into Floats.
8
+ # into Floats. Blank strings are treated as a `nil` value.
9
9
  #
10
10
  # @!macro filter_method_params
11
11
  #
@@ -25,6 +25,26 @@ module ActiveInteraction
25
25
 
26
26
  register :hash
27
27
 
28
+ def process(value, context)
29
+ input = super
30
+
31
+ return HashInput.new(self, value: input.value, error: input.errors.first) if input.errors.first
32
+ return HashInput.new(self, value: default(context), error: input.errors.first) if input.value.nil?
33
+
34
+ value = strip? ? HashWithIndifferentAccess.new : input.value
35
+ error = nil
36
+ children = {}
37
+
38
+ filters.each do |name, filter|
39
+ filter.process(input.value[name], context).tap do |result|
40
+ value[name] = result.value
41
+ children[name.to_sym] = result
42
+ end
43
+ end
44
+
45
+ HashInput.new(self, value: value, error: error, children: children)
46
+ end
47
+
28
48
  private
29
49
 
30
50
  def matches?(value)
@@ -33,29 +53,17 @@ module ActiveInteraction
33
53
  false
34
54
  end
35
55
 
36
- def clean_value(hash, name, filter, value, context)
37
- hash[name] = filter.clean(value[name], context)
38
- rescue InvalidValueError, MissingValueError
39
- raise InvalidNestedValueError.new(name, value[name])
40
- end
41
-
42
56
  def strip?
43
57
  options.fetch(:strip, true)
44
58
  end
45
59
 
46
- def adjust_output(value, context)
47
- value = ActiveSupport::HashWithIndifferentAccess.new(value.to_hash)
48
-
49
- initial = strip? ? ActiveSupport::HashWithIndifferentAccess.new : value
50
-
51
- filters.each_with_object(initial) do |(name, filter), hash|
52
- clean_value(hash, name.to_s, filter, value, context)
53
- end
60
+ def adjust_output(value, _context)
61
+ ActiveSupport::HashWithIndifferentAccess.new(value)
54
62
  end
55
63
 
56
64
  def convert(value)
57
65
  if value.respond_to?(:to_hash)
58
- value.to_hash
66
+ [value.to_hash, nil]
59
67
  else
60
68
  super
61
69
  end
@@ -5,7 +5,7 @@ module ActiveInteraction
5
5
  # @!method self.integer(*attributes, options = {})
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are Integers. String values are converted into
8
- # Integers.
8
+ # Integers. Blank strings are treated as a `nil` value.
9
9
  #
10
10
  # @!macro filter_method_params
11
11
  # @option options [Integer] :base (10) The base used to convert strings
@@ -47,34 +47,34 @@ module ActiveInteraction
47
47
  "constant #{const_name.inspect} does not exist"
48
48
  end
49
49
 
50
- def matches?(object)
51
- return false if object.nil?
52
- return matches_methods?(object) if options.key?(:methods)
50
+ def matches?(value)
51
+ return false if value == nil # rubocop:disable Style/NilComparison
52
+ return matches_methods?(value) if options.key?(:methods)
53
53
 
54
54
  const = from
55
- if checking_class_inheritance?(object, const)
56
- class_inherits_from?(object, const)
55
+ if checking_class_inheritance?(value, const)
56
+ class_inherits_from?(value, const)
57
57
  else
58
- singleton_ancestor?(object, const)
58
+ singleton_ancestor?(value, const)
59
59
  end
60
60
  rescue NoMethodError
61
61
  false
62
62
  end
63
63
 
64
- def matches_methods?(object)
65
- options[:methods].all? { |method| object.respond_to?(method) }
64
+ def matches_methods?(value)
65
+ options[:methods].all? { |method| value.respond_to?(method) }
66
66
  end
67
67
 
68
- def checking_class_inheritance?(object, from)
69
- object.is_a?(Class) && from.is_a?(Class)
68
+ def checking_class_inheritance?(value, from)
69
+ value.is_a?(Class) && from.is_a?(Class)
70
70
  end
71
71
 
72
72
  def class_inherits_from?(klass, inherits_from)
73
73
  klass != inherits_from && klass.ancestors.include?(inherits_from)
74
74
  end
75
75
 
76
- def singleton_ancestor?(object, from)
77
- object.class != from && object.singleton_class.ancestors.include?(from)
76
+ def singleton_ancestor?(value, from)
77
+ value.class != from && value.singleton_class.ancestors.include?(from)
78
78
  end
79
79
  end
80
80
  end
@@ -38,19 +38,25 @@ module ActiveInteraction
38
38
  end
39
39
 
40
40
  def matches?(value)
41
+ return false if value == nil # rubocop:disable Style/NilComparison
42
+
41
43
  value.class <= klass
42
44
  rescue NoMethodError
43
45
  false
44
46
  end
45
47
 
46
48
  def convert(value)
47
- converter(value).tap do |result|
48
- raise InvalidValueError if result.nil?
49
+ result = converter(value)
50
+
51
+ if result.nil?
52
+ [value, Filter::Error.new(self, :invalid_type)]
53
+ else
54
+ [result, nil]
49
55
  end
50
56
  rescue StandardError => e
51
57
  raise e if e.is_a?(InvalidConverterError)
52
58
 
53
- raise InvalidValueError
59
+ [value, Filter::Error.new(self, :invalid_type)]
54
60
  end
55
61
 
56
62
  def converter(value)
@@ -4,7 +4,9 @@ module ActiveInteraction
4
4
  class Base # rubocop:disable Lint/EmptyClass
5
5
  # @!method self.record(*attributes, options = {})
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
- # the attributes are the correct class.
7
+ # the attributes are the correct class. Blank strings passed in will be
8
+ # treated as `nil` and the `finder` will not be called.
9
+
8
10
  #
9
11
  # @!macro filter_method_params
10
12
  # @option options [Class, String, Symbol] :class (use the attribute name)
@@ -44,20 +46,28 @@ module ActiveInteraction
44
46
  end
45
47
 
46
48
  def convert(value)
47
- finder = options.fetch(:finder, :find)
48
- find(klass, value, finder)
49
- end
49
+ return [nil, nil] if blank_string?(value)
50
50
 
51
- def find(klass, value, finder)
52
- result = klass.public_send(finder, value)
51
+ finder = options.fetch(:finder, :find)
52
+ result = find(klass, value, finder)
53
53
 
54
- raise InvalidValueError if result.nil?
54
+ if result.nil?
55
+ [value, Filter::Error.new(self, :invalid_type)]
56
+ else
57
+ [result, nil]
58
+ end
59
+ end
55
60
 
56
- result
57
- rescue StandardError => e
58
- raise e if e.is_a?(InvalidConverterError)
61
+ def blank_string?(value)
62
+ value.is_a?(String) && value.blank?
63
+ rescue NoMethodError # BasicObject
64
+ false
65
+ end
59
66
 
60
- raise InvalidValueError
67
+ def find(klass, value, finder)
68
+ klass.public_send(finder, value)
69
+ rescue StandardError
70
+ nil
61
71
  end
62
72
  end
63
73
  end
@@ -38,7 +38,7 @@ module ActiveInteraction
38
38
 
39
39
  def convert(value)
40
40
  if value.respond_to?(:to_str)
41
- value.to_str
41
+ [value.to_str, nil]
42
42
  else
43
43
  super
44
44
  end
@@ -26,7 +26,7 @@ module ActiveInteraction
26
26
 
27
27
  def convert(value)
28
28
  if value.respond_to?(:to_sym)
29
- value.to_sym
29
+ [value.to_sym, nil]
30
30
  else
31
31
  super
32
32
  end
@@ -6,9 +6,9 @@ module ActiveInteraction
6
6
  # Creates accessors for the attributes and ensures that values passed to
7
7
  # the attributes are Times. Numeric values are processed using `at`.
8
8
  # Strings are processed using `parse` unless the format option is
9
- # given, in which case they will be processed with `strptime`. If
10
- # `Time.zone` is available it will be used so that the values are time
11
- # zone aware.
9
+ # given, in which case they will be processed with `strptime`. Blank
10
+ # strings are treated as a `nil` value. If `Time.zone` is available it
11
+ # will be used so that the values are time zone aware.
12
12
  #
13
13
  # @!macro filter_method_params
14
14
  # @option options [String] :format parse strings using this format string
@@ -59,7 +59,7 @@ module ActiveInteraction
59
59
  value = value.to_int if value.respond_to?(:to_int)
60
60
 
61
61
  if value.is_a?(Numeric)
62
- klass.at(value)
62
+ [klass.at(value), nil]
63
63
  else
64
64
  super
65
65
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteraction
4
+ # Represents a processed hash input.
5
+ class HashInput < Input
6
+ # @private
7
+ def initialize(filter, value: nil, error: nil, children: {})
8
+ super(filter, value: value, error: error)
9
+
10
+ @children = children
11
+ end
12
+
13
+ # @overload children
14
+ # Child inputs if nested filters are used.
15
+ #
16
+ # @return [Hash{ Symbol => Input, ArrayInput, HashInput }]
17
+ attr_reader :children
18
+
19
+ # Any errors that occurred during processing.
20
+ #
21
+ # @return [Filter::Error]
22
+ def errors
23
+ return @errors if defined?(@errors)
24
+
25
+ return @errors = super if @error
26
+
27
+ child_errors = get_errors(children)
28
+
29
+ return @errors = super if child_errors.empty?
30
+
31
+ @errors ||=
32
+ child_errors.map do |error|
33
+ Filter::Error.new(error.filter, error.type, name: :"#{@filter.name}.#{error.name}")
34
+ end.freeze
35
+ end
36
+
37
+ private
38
+
39
+ def get_errors(children)
40
+ children.values.flat_map(&:errors)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteraction
4
+ # Represents a processed input.
5
+ class Input
6
+ # @private
7
+ def initialize(filter, value: nil, error: nil)
8
+ @filter = filter
9
+ @value = value
10
+ @error = error
11
+ end
12
+
13
+ # The processed input value.
14
+ attr_reader :value
15
+
16
+ # Any errors that occurred during processing.
17
+ #
18
+ # @return [Filter::Error]
19
+ def errors
20
+ @errors ||= Array(@error)
21
+ end
22
+ end
23
+ end