active_interaction 4.1.0 → 5.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -1
  3. data/CONTRIBUTING.md +11 -3
  4. data/README.md +256 -215
  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 +40 -52
  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 +27 -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 +43 -44
  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