active_interaction 4.0.6 → 5.1.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +156 -7
  3. data/CONTRIBUTING.md +11 -3
  4. data/README.md +260 -219
  5. data/lib/active_interaction/array_input.rb +77 -0
  6. data/lib/active_interaction/base.rb +14 -98
  7. data/lib/active_interaction/concerns/active_recordable.rb +3 -3
  8. data/lib/active_interaction/concerns/missable.rb +2 -2
  9. data/lib/active_interaction/errors.rb +6 -88
  10. data/lib/active_interaction/exceptions.rb +47 -0
  11. data/lib/active_interaction/filter/column.rb +59 -0
  12. data/lib/active_interaction/filter/error.rb +40 -0
  13. data/lib/active_interaction/filter.rb +44 -53
  14. data/lib/active_interaction/filters/abstract_date_time_filter.rb +9 -6
  15. data/lib/active_interaction/filters/abstract_numeric_filter.rb +7 -3
  16. data/lib/active_interaction/filters/array_filter.rb +40 -6
  17. data/lib/active_interaction/filters/boolean_filter.rb +4 -3
  18. data/lib/active_interaction/filters/date_filter.rb +1 -1
  19. data/lib/active_interaction/filters/date_time_filter.rb +1 -1
  20. data/lib/active_interaction/filters/decimal_filter.rb +1 -1
  21. data/lib/active_interaction/filters/float_filter.rb +1 -1
  22. data/lib/active_interaction/filters/hash_filter.rb +23 -15
  23. data/lib/active_interaction/filters/integer_filter.rb +1 -1
  24. data/lib/active_interaction/filters/interface_filter.rb +12 -12
  25. data/lib/active_interaction/filters/object_filter.rb +9 -3
  26. data/lib/active_interaction/filters/record_filter.rb +21 -11
  27. data/lib/active_interaction/filters/string_filter.rb +1 -1
  28. data/lib/active_interaction/filters/symbol_filter.rb +1 -1
  29. data/lib/active_interaction/filters/time_filter.rb +4 -4
  30. data/lib/active_interaction/hash_input.rb +43 -0
  31. data/lib/active_interaction/input.rb +23 -0
  32. data/lib/active_interaction/inputs.rb +161 -46
  33. data/lib/active_interaction/locale/en.yml +0 -1
  34. data/lib/active_interaction/locale/fr.yml +0 -1
  35. data/lib/active_interaction/locale/it.yml +0 -1
  36. data/lib/active_interaction/locale/ja.yml +0 -1
  37. data/lib/active_interaction/locale/pt-BR.yml +0 -1
  38. data/lib/active_interaction/modules/validation.rb +6 -17
  39. data/lib/active_interaction/version.rb +1 -1
  40. data/lib/active_interaction.rb +41 -36
  41. data/spec/active_interaction/array_input_spec.rb +166 -0
  42. data/spec/active_interaction/base_spec.rb +34 -248
  43. data/spec/active_interaction/concerns/active_modelable_spec.rb +3 -3
  44. data/spec/active_interaction/concerns/active_recordable_spec.rb +7 -7
  45. data/spec/active_interaction/concerns/hashable_spec.rb +8 -8
  46. data/spec/active_interaction/concerns/missable_spec.rb +9 -9
  47. data/spec/active_interaction/concerns/runnable_spec.rb +34 -32
  48. data/spec/active_interaction/errors_spec.rb +60 -43
  49. data/spec/active_interaction/{filter_column_spec.rb → filter/column_spec.rb} +3 -10
  50. data/spec/active_interaction/filter_spec.rb +6 -6
  51. data/spec/active_interaction/filters/abstract_date_time_filter_spec.rb +2 -2
  52. data/spec/active_interaction/filters/abstract_numeric_filter_spec.rb +2 -2
  53. data/spec/active_interaction/filters/array_filter_spec.rb +109 -16
  54. data/spec/active_interaction/filters/boolean_filter_spec.rb +12 -11
  55. data/spec/active_interaction/filters/date_filter_spec.rb +32 -27
  56. data/spec/active_interaction/filters/date_time_filter_spec.rb +34 -29
  57. data/spec/active_interaction/filters/decimal_filter_spec.rb +20 -18
  58. data/spec/active_interaction/filters/file_filter_spec.rb +7 -7
  59. data/spec/active_interaction/filters/float_filter_spec.rb +19 -17
  60. data/spec/active_interaction/filters/hash_filter_spec.rb +16 -18
  61. data/spec/active_interaction/filters/integer_filter_spec.rb +24 -22
  62. data/spec/active_interaction/filters/interface_filter_spec.rb +105 -82
  63. data/spec/active_interaction/filters/object_filter_spec.rb +52 -36
  64. data/spec/active_interaction/filters/record_filter_spec.rb +61 -39
  65. data/spec/active_interaction/filters/string_filter_spec.rb +7 -7
  66. data/spec/active_interaction/filters/symbol_filter_spec.rb +6 -6
  67. data/spec/active_interaction/filters/time_filter_spec.rb +57 -34
  68. data/spec/active_interaction/hash_input_spec.rb +58 -0
  69. data/spec/active_interaction/i18n_spec.rb +22 -17
  70. data/spec/active_interaction/inputs_spec.rb +170 -18
  71. data/spec/active_interaction/integration/array_interaction_spec.rb +3 -7
  72. data/spec/active_interaction/integration/record_integration_spec.rb +5 -0
  73. data/spec/active_interaction/modules/validation_spec.rb +8 -31
  74. data/spec/spec_helper.rb +9 -0
  75. data/spec/support/concerns.rb +2 -2
  76. data/spec/support/filters.rb +27 -51
  77. data/spec/support/interactions.rb +4 -4
  78. metadata +50 -50
  79. data/lib/active_interaction/filter_column.rb +0 -57
@@ -31,14 +31,16 @@ module ActiveInteraction
31
31
  def convert(value)
32
32
  if value.respond_to?(:to_str)
33
33
  value = value.to_str
34
- value.blank? ? send(__method__, nil) : convert_string(value)
34
+ if value.blank?
35
+ send(__method__, nil)
36
+ else
37
+ [convert_string(value), nil]
38
+ end
35
39
  elsif value.is_a?(GroupedInput)
36
- convert_grouped_input(value)
40
+ [convert_grouped_input(value), nil]
37
41
  else
38
42
  super
39
43
  end
40
- rescue ArgumentError
41
- value
42
44
  rescue NoMethodError # BasicObject
43
45
  super
44
46
  end
@@ -47,9 +49,10 @@ module ActiveInteraction
47
49
  if format?
48
50
  klass.strptime(value, format)
49
51
  else
50
- klass.parse(value) ||
51
- (raise ArgumentError, "no time information in #{value.inspect}")
52
+ klass.parse(value) || value
52
53
  end
54
+ rescue ArgumentError
55
+ value
53
56
  end
54
57
 
55
58
  def convert_grouped_input(value)
@@ -21,12 +21,16 @@ module ActiveInteraction
21
21
 
22
22
  def convert(value)
23
23
  if value.is_a?(Numeric)
24
- safe_converter(value)
24
+ [safe_converter(value), nil]
25
25
  elsif value.respond_to?(:to_int)
26
- safe_converter(value.to_int)
26
+ [safe_converter(value.to_int), nil]
27
27
  elsif value.respond_to?(:to_str)
28
28
  value = value.to_str
29
- value.blank? ? send(__method__, nil) : safe_converter(value)
29
+ if value.blank?
30
+ send(__method__, nil)
31
+ else
32
+ [safe_converter(value), nil]
33
+ end
30
34
  else
31
35
  super
32
36
  end
@@ -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,43 @@ 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.map! do |item|
53
+ result = filters[:'0'].process(item, context)
54
+ children.push(result)
55
+ result.value
56
+ end
57
+ end
58
+
59
+ ArrayInput.new(self, value: value, error: error, children: children, index_errors: index_errors?)
60
+ end
61
+
39
62
  private
40
63
 
64
+ def index_errors?
65
+ klass = 'ActiveRecord'.safe_constantize
66
+
67
+ default =
68
+ if !klass
69
+ false
70
+ elsif klass.respond_to?(:index_nested_attribute_errors)
71
+ klass.index_nested_attribute_errors # Moved to here in Rails 7.0
72
+ else
73
+ klass::Base.index_nested_attribute_errors
74
+ end
75
+ options.fetch(:index_errors, default)
76
+ end
77
+
41
78
  def klasses
42
79
  %w[
43
80
  ActiveRecord::Relation
@@ -55,16 +92,13 @@ module ActiveInteraction
55
92
  false
56
93
  end
57
94
 
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) }
95
+ def adjust_output(value, _context)
96
+ value.to_a
63
97
  end
64
98
 
65
99
  def convert(value)
66
100
  if value.respond_to?(:to_ary)
67
- value.to_ary
101
+ [value.to_ary, nil]
68
102
  else
69
103
  super
70
104
  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