active_interaction 3.8.0 → 4.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +187 -0
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +93 -107
  5. data/lib/active_interaction.rb +1 -7
  6. data/lib/active_interaction/base.rb +23 -26
  7. data/lib/active_interaction/concerns/active_modelable.rb +1 -3
  8. data/lib/active_interaction/concerns/active_recordable.rb +1 -6
  9. data/lib/active_interaction/concerns/hashable.rb +0 -1
  10. data/lib/active_interaction/concerns/missable.rb +0 -1
  11. data/lib/active_interaction/concerns/runnable.rb +15 -25
  12. data/lib/active_interaction/errors.rb +6 -13
  13. data/lib/active_interaction/filter.rb +51 -37
  14. data/lib/active_interaction/filter_column.rb +0 -3
  15. data/lib/active_interaction/filters/abstract_date_time_filter.rb +34 -36
  16. data/lib/active_interaction/filters/abstract_numeric_filter.rb +27 -17
  17. data/lib/active_interaction/filters/array_filter.rb +57 -36
  18. data/lib/active_interaction/filters/boolean_filter.rb +26 -12
  19. data/lib/active_interaction/filters/date_filter.rb +1 -2
  20. data/lib/active_interaction/filters/date_time_filter.rb +1 -2
  21. data/lib/active_interaction/filters/decimal_filter.rb +10 -28
  22. data/lib/active_interaction/filters/file_filter.rb +6 -5
  23. data/lib/active_interaction/filters/float_filter.rb +1 -2
  24. data/lib/active_interaction/filters/hash_filter.rb +37 -27
  25. data/lib/active_interaction/filters/integer_filter.rb +7 -8
  26. data/lib/active_interaction/filters/interface_filter.rb +48 -14
  27. data/lib/active_interaction/filters/object_filter.rb +23 -50
  28. data/lib/active_interaction/filters/record_filter.rb +10 -35
  29. data/lib/active_interaction/filters/string_filter.rb +21 -12
  30. data/lib/active_interaction/filters/symbol_filter.rb +13 -7
  31. data/lib/active_interaction/filters/time_filter.rb +24 -19
  32. data/lib/active_interaction/grouped_input.rb +0 -3
  33. data/lib/active_interaction/inputs.rb +95 -0
  34. data/lib/active_interaction/modules/validation.rb +9 -12
  35. data/lib/active_interaction/version.rb +1 -3
  36. data/spec/active_interaction/base_spec.rb +22 -35
  37. data/spec/active_interaction/concerns/active_modelable_spec.rb +0 -2
  38. data/spec/active_interaction/concerns/active_recordable_spec.rb +0 -2
  39. data/spec/active_interaction/concerns/hashable_spec.rb +0 -2
  40. data/spec/active_interaction/concerns/missable_spec.rb +0 -2
  41. data/spec/active_interaction/concerns/runnable_spec.rb +26 -12
  42. data/spec/active_interaction/errors_spec.rb +4 -25
  43. data/spec/active_interaction/filter_column_spec.rb +0 -2
  44. data/spec/active_interaction/filter_spec.rb +0 -2
  45. data/spec/active_interaction/filters/abstract_date_time_filter_spec.rb +1 -3
  46. data/spec/active_interaction/filters/abstract_numeric_filter_spec.rb +1 -3
  47. data/spec/active_interaction/filters/array_filter_spec.rb +41 -15
  48. data/spec/active_interaction/filters/boolean_filter_spec.rb +58 -4
  49. data/spec/active_interaction/filters/date_filter_spec.rb +43 -3
  50. data/spec/active_interaction/filters/date_time_filter_spec.rb +43 -3
  51. data/spec/active_interaction/filters/decimal_filter_spec.rb +57 -3
  52. data/spec/active_interaction/filters/file_filter_spec.rb +1 -3
  53. data/spec/active_interaction/filters/float_filter_spec.rb +60 -4
  54. data/spec/active_interaction/filters/hash_filter_spec.rb +19 -9
  55. data/spec/active_interaction/filters/integer_filter_spec.rb +49 -7
  56. data/spec/active_interaction/filters/interface_filter_spec.rb +397 -24
  57. data/spec/active_interaction/filters/object_filter_spec.rb +23 -59
  58. data/spec/active_interaction/filters/record_filter_spec.rb +23 -49
  59. data/spec/active_interaction/filters/string_filter_spec.rb +15 -3
  60. data/spec/active_interaction/filters/symbol_filter_spec.rb +15 -3
  61. data/spec/active_interaction/filters/time_filter_spec.rb +65 -3
  62. data/spec/active_interaction/grouped_input_spec.rb +0 -2
  63. data/spec/active_interaction/i18n_spec.rb +3 -7
  64. data/spec/active_interaction/{modules/input_processor_spec.rb → inputs_spec.rb} +3 -5
  65. data/spec/active_interaction/integration/array_interaction_spec.rb +18 -22
  66. data/spec/active_interaction/integration/boolean_interaction_spec.rb +0 -2
  67. data/spec/active_interaction/integration/date_interaction_spec.rb +0 -2
  68. data/spec/active_interaction/integration/date_time_interaction_spec.rb +0 -2
  69. data/spec/active_interaction/integration/file_interaction_spec.rb +0 -2
  70. data/spec/active_interaction/integration/float_interaction_spec.rb +0 -2
  71. data/spec/active_interaction/integration/hash_interaction_spec.rb +0 -2
  72. data/spec/active_interaction/integration/integer_interaction_spec.rb +0 -2
  73. data/spec/active_interaction/integration/interface_interaction_spec.rb +1 -3
  74. data/spec/active_interaction/integration/object_interaction_spec.rb +0 -2
  75. data/spec/active_interaction/integration/string_interaction_spec.rb +0 -2
  76. data/spec/active_interaction/integration/symbol_interaction_spec.rb +0 -2
  77. data/spec/active_interaction/integration/time_interaction_spec.rb +14 -18
  78. data/spec/active_interaction/modules/validation_spec.rb +1 -3
  79. data/spec/spec_helper.rb +2 -6
  80. data/spec/support/concerns.rb +0 -2
  81. data/spec/support/filters.rb +13 -9
  82. data/spec/support/interactions.rb +22 -14
  83. metadata +81 -57
  84. data/lib/active_interaction/backports.rb +0 -59
  85. data/lib/active_interaction/filters/abstract_filter.rb +0 -19
  86. data/lib/active_interaction/modules/input_processor.rb +0 -52
  87. data/spec/active_interaction/filters/abstract_filter_spec.rb +0 -8
@@ -1,8 +1,7 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveInteraction
5
- class Base
4
+ class Base # rubocop:disable Lint/EmptyClass
6
5
  # @!method self.symbol(*attributes, options = {})
7
6
  # Creates accessors for the attributes and ensures that values passed to
8
7
  # the attributes are Symbols. Strings will be converted to Symbols.
@@ -17,15 +16,22 @@ module ActiveInteraction
17
16
  class SymbolFilter < Filter
18
17
  register :symbol
19
18
 
20
- def cast(value, _interaction)
21
- case value
22
- when Symbol
23
- value
24
- when String
19
+ private
20
+
21
+ def matches?(value)
22
+ value.is_a?(Symbol)
23
+ rescue NoMethodError # BasicObject
24
+ false
25
+ end
26
+
27
+ def convert(value)
28
+ if value.respond_to?(:to_sym)
25
29
  value.to_sym
26
30
  else
27
31
  super
28
32
  end
33
+ rescue NoMethodError # BasicObject
34
+ super
29
35
  end
30
36
  end
31
37
  end
@@ -1,8 +1,7 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveInteraction
5
- class Base
4
+ class Base # rubocop:disable Lint/EmptyClass
6
5
  # @!method self.time(*attributes, options = {})
7
6
  # Creates accessors for the attributes and ensures that values passed to
8
7
  # the attributes are Times. Numeric values are processed using `at`.
@@ -24,34 +23,24 @@ module ActiveInteraction
24
23
  class TimeFilter < AbstractDateTimeFilter
25
24
  register :time
26
25
 
27
- alias _klass klass
28
- private :_klass
29
-
30
26
  def initialize(name, options = {}, &block)
31
- if options.key?(:format) && klass != Time
32
- raise InvalidFilterError, 'format option unsupported with time zones'
33
- end
27
+ raise InvalidFilterError, 'format option unsupported with time zones' if options.key?(:format) && time_with_zone?
34
28
 
35
29
  super
36
30
  end
37
31
 
38
- def cast(value, _interaction)
39
- case value
40
- when Numeric
41
- klass.at(value)
42
- else
43
- super
44
- end
45
- end
46
-
47
32
  def database_column_type
48
33
  :datetime
49
34
  end
50
35
 
51
36
  private
52
37
 
38
+ def time_with_zone?
39
+ Time.respond_to?(:zone) && !Time.zone.nil?
40
+ end
41
+
53
42
  def klass
54
- if Time.respond_to?(:zone) && !Time.zone.nil?
43
+ if time_with_zone?
55
44
  Time.zone
56
45
  else
57
46
  super
@@ -59,7 +48,23 @@ module ActiveInteraction
59
48
  end
60
49
 
61
50
  def klasses
62
- [_klass, klass.at(0).class]
51
+ if time_with_zone?
52
+ [Time.zone.at(0).class, Time]
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def convert(value)
59
+ value = value.to_int if value.respond_to?(:to_int)
60
+
61
+ if value.is_a?(Numeric)
62
+ klass.at(value)
63
+ else
64
+ super
65
+ end
66
+ rescue NoMethodError # BasicObject
67
+ super
63
68
  end
64
69
  end
65
70
  end
@@ -1,4 +1,3 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require 'ostruct'
@@ -6,8 +5,6 @@ require 'ostruct'
6
5
  module ActiveInteraction
7
6
  # Holds a group of inputs together for passing from {Base} to {Filter}s.
8
7
  #
9
- # @since 1.2.0
10
- #
11
8
  # @private
12
9
  class GroupedInput < OpenStruct
13
10
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteraction
4
+ # Holds inputs passed to the interaction.
5
+ class Inputs < DelegateClass(Hash)
6
+ class << self
7
+ # matches inputs like "key(1i)"
8
+ GROUPED_INPUT_PATTERN = /
9
+ \A
10
+ (?<key>.+) # extracts "key"
11
+ \((?<index>\d+)i\) # extracts "1"
12
+ \z
13
+ /x.freeze
14
+ private_constant :GROUPED_INPUT_PATTERN
15
+
16
+ # Checking `syscall` is the result of what appears to be a bug in Ruby.
17
+ # https://bugs.ruby-lang.org/issues/15597
18
+ def reserved?(name)
19
+ name.to_s.start_with?('_interaction_') ||
20
+ name == :syscall ||
21
+ (
22
+ Base.method_defined?(name) &&
23
+ !Object.method_defined?(name)
24
+ ) ||
25
+ (
26
+ Base.private_method_defined?(name) &&
27
+ !Object.private_method_defined?(name)
28
+ )
29
+ end
30
+
31
+ def process(inputs)
32
+ inputs.stringify_keys.sort.each_with_object({}) do |(k, v), h|
33
+ next if reserved?(k)
34
+
35
+ if (group = GROUPED_INPUT_PATTERN.match(k))
36
+ assign_to_grouped_input!(h, group[:key], group[:index], v)
37
+ else
38
+ h[k.to_sym] = v
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def assign_to_grouped_input!(inputs, key, index, value)
46
+ key = key.to_sym
47
+
48
+ inputs[key] = GroupedInput.new unless inputs[key].is_a?(GroupedInput)
49
+ inputs[key][index] = value
50
+ end
51
+ end
52
+
53
+ def initialize
54
+ @groups = {}
55
+ @groups.default_proc = ->(hash, key) { hash[key] = [] }
56
+
57
+ super(@inputs = {})
58
+ end
59
+
60
+ # Associates the `value` with the `key`. Allows the `key`/`value` pair to
61
+ # be associated with one or more groups.
62
+ #
63
+ # @example
64
+ # inputs.store(:key, :value)
65
+ # # => :value
66
+ # inputs.store(:key, :value, %i[a b])
67
+ # # => :value
68
+ #
69
+ # @param key [Object] The key to store the value under.
70
+ # @param value [Object] The value to store.
71
+ # @param groups [Array<Object>] The groups to store the pair under.
72
+ #
73
+ # @return [Object] value
74
+ def store(key, value, groups = [])
75
+ groups.each do |group|
76
+ @groups[group] << key
77
+ end
78
+
79
+ super(key, value)
80
+ end
81
+
82
+ # Returns inputs from the group name given.
83
+ #
84
+ # @example
85
+ # inputs.group(:a)
86
+ # # => {key: :value}
87
+ #
88
+ # @param name [Object] Name of the group to return.
89
+ #
90
+ # @return [Hash] Inputs from the group name given.
91
+ def group(name)
92
+ @inputs.select { |k, _| @groups[name].include?(k) }
93
+ end
94
+ end
95
+ end
@@ -1,4 +1,3 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActiveInteraction
@@ -12,14 +11,13 @@ module ActiveInteraction
12
11
  # @param inputs [Hash{Symbol => Object}]
13
12
  def validate(context, filters, inputs)
14
13
  filters.each_with_object([]) do |(name, filter), errors|
15
- begin
16
- filter.clean(inputs[name], context)
17
- rescue NoDefaultError
18
- nil
19
- rescue InvalidNestedValueError,
20
- InvalidValueError, MissingValueError => e
21
- errors << error_args(filter, e)
22
- end
14
+ filter.clean(inputs[name], context)
15
+ rescue NoDefaultError
16
+ nil
17
+ rescue InvalidNestedValueError,
18
+ InvalidValueError,
19
+ MissingValueError => e
20
+ errors << error_args(filter, e)
23
21
  end
24
22
  end
25
23
 
@@ -28,10 +26,9 @@ module ActiveInteraction
28
26
  def error_args(filter, error)
29
27
  case error
30
28
  when InvalidNestedValueError
31
- [filter.name, :invalid_nested,
32
- name: error.filter_name.inspect, value: error.input_value.inspect]
29
+ [filter.name, :invalid_nested, { name: error.filter_name.inspect, value: error.input_value.inspect }]
33
30
  when InvalidValueError
34
- [filter.name, :invalid_type, type: type(filter)]
31
+ [filter.name, :invalid_type, { type: type(filter) }]
35
32
  when MissingValueError
36
33
  [filter.name, :missing]
37
34
  end
@@ -1,10 +1,8 @@
1
- # coding: utf-8
2
1
  # frozen_string_literal: true
3
2
 
4
- #
5
3
  module ActiveInteraction
6
4
  # The version number.
7
5
  #
8
6
  # @return [Gem::Version]
9
- VERSION = Gem::Version.new('3.8.0')
7
+ VERSION = Gem::Version.new('4.0.1')
10
8
  end
@@ -1,5 +1,3 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
  require 'action_controller'
5
3
 
@@ -24,6 +22,12 @@ InterruptInteraction = Class.new(TestInteraction) do
24
22
  class: Object,
25
23
  default: nil
26
24
 
25
+ # NOTE: the relative position between this method
26
+ # and the compose line should be preserved.
27
+ def self.composition_location
28
+ "#{__FILE__}:#{__LINE__ + 4}:in `execute'"
29
+ end
30
+
27
31
  def execute
28
32
  compose(AddInteraction, x: x, y: z)
29
33
  end
@@ -51,7 +55,7 @@ describe ActiveInteraction::Base do
51
55
  end
52
56
 
53
57
  context 'with non-hash inputs' do
54
- let(:inputs) { [[:k, :v]] }
58
+ let(:inputs) { [%i[k v]] }
55
59
 
56
60
  it 'raises an error' do
57
61
  expect { interaction }.to raise_error ArgumentError
@@ -371,6 +375,13 @@ describe ActiveInteraction::Base do
371
375
  expect(outcome.errors.details)
372
376
  .to eql(x: [{ error: :missing }], base: [{ error: 'Y is required' }])
373
377
  end
378
+
379
+ it 'has the correct backtrace' do
380
+ described_class.run!(inputs)
381
+ rescue ActiveInteraction::InvalidInteractionError => e
382
+ expect(e.backtrace)
383
+ .to include(InterruptInteraction.composition_location)
384
+ end
374
385
  end
375
386
  end
376
387
 
@@ -628,32 +639,6 @@ describe ActiveInteraction::Base do
628
639
  end
629
640
  end
630
641
 
631
- context 'predicates' do
632
- let(:described_class) { InteractionWithFilter }
633
-
634
- it 'responds to the predicate' do
635
- expect(interaction.respond_to?(:thing?)).to be_truthy
636
- end
637
-
638
- context 'without a value' do
639
- it 'returns false' do
640
- expect(interaction.thing?).to be_falsey
641
- end
642
- end
643
-
644
- context 'with a value' do
645
- let(:thing) { rand }
646
-
647
- before do
648
- inputs[:thing] = thing
649
- end
650
-
651
- it 'returns true' do
652
- expect(interaction.thing?).to be_truthy
653
- end
654
- end
655
- end
656
-
657
642
  describe '.import_filters' do
658
643
  shared_context 'import_filters context' do |only, except|
659
644
  let(:klass) { AddInteraction }
@@ -672,9 +657,11 @@ describe ActiveInteraction::Base do
672
657
  include_context 'import_filters context', only, except
673
658
 
674
659
  it 'imports the filters' do
675
- expect(described_class.filters).to eql klass.filters
676
- .select { |k, _| only.nil? ? true : [*only].include?(k) }
677
- .reject { |k, _| except.nil? ? false : [*except].include?(k) }
660
+ expect(described_class.filters).to eql(
661
+ klass.filters
662
+ .select { |k, _| only.nil? ? true : [*only].include?(k) }
663
+ .reject { |k, _| except.nil? ? false : [*except].include?(k) }
664
+ )
678
665
  end
679
666
 
680
667
  it 'does not modify the source' do
@@ -683,11 +670,11 @@ describe ActiveInteraction::Base do
683
670
  expect(klass.filters).to eql filters
684
671
  end
685
672
 
686
- it 'responds to readers, writers, and predicates' do
673
+ it 'responds to readers and writers' do
687
674
  instance = described_class.new
688
675
 
689
- described_class.filters.keys.each do |name|
690
- [name, "#{name}=", "#{name}?"].each do |method|
676
+ described_class.filters.each_key do |name|
677
+ [name, "#{name}="].each do |method|
691
678
  expect(instance).to respond_to method
692
679
  end
693
680
  end
@@ -1,5 +1,3 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
 
5
3
  shared_examples_for 'ActiveModel' do
@@ -1,5 +1,3 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
 
5
3
  InteractionWithFloatFilter = Class.new(TestInteraction) do
@@ -1,5 +1,3 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
 
5
3
  describe ActiveInteraction::Hashable do
@@ -1,5 +1,3 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
 
5
3
  describe ActiveInteraction::Missable do
@@ -1,11 +1,9 @@
1
- # coding: utf-8
2
-
3
1
  require 'spec_helper'
4
2
 
5
3
  describe ActiveInteraction::Runnable do
6
4
  include_context 'concerns', ActiveInteraction::Runnable
7
5
 
8
- class WrappableFailingInteraction
6
+ class WrappableFailingInteraction # rubocop:disable Lint/ConstantDefinitionInBlock
9
7
  include ActiveInteraction::Runnable
10
8
 
11
9
  def execute
@@ -72,7 +70,7 @@ describe ActiveInteraction::Runnable do
72
70
  include_examples 'set_callback examples', :execute
73
71
 
74
72
  context 'execute with composed interaction' do
75
- class WithFailingCompose
73
+ class WithFailingCompose # rubocop:disable Lint/ConstantDefinitionInBlock
76
74
  include ActiveInteraction::Runnable
77
75
 
78
76
  def execute
@@ -107,11 +105,9 @@ describe ActiveInteraction::Runnable do
107
105
  context 'using if' do
108
106
  it 'yields errors to the if' do
109
107
  has_run = false
110
- # rubocop:disable Metrics/LineLength
111
108
  WithFailingCompose.set_callback :execute, :after, if: -> { errors.any? } do
112
109
  has_run = true
113
110
  end
114
- # rubocop:enable Metrics/LineLength
115
111
 
116
112
  WithFailingCompose.run
117
113
  expect(has_run).to be_truthy
@@ -237,10 +233,10 @@ describe ActiveInteraction::Runnable do
237
233
  context 'caches the validity and result of the run' do
238
234
  let(:klass) do
239
235
  Class.new(ActiveInteraction::Base) do
240
- INVALID = [false, true].cycle
236
+ invalid = [false, true].cycle
241
237
 
242
238
  validate do |interaction|
243
- interaction.errors.add(:base, 'failed') unless INVALID.next
239
+ interaction.errors.add(:base, 'failed') unless invalid.next
244
240
  end
245
241
 
246
242
  def execute
@@ -260,10 +256,10 @@ describe ActiveInteraction::Runnable do
260
256
  context 'caches the validity and result of the run' do
261
257
  let(:klass) do
262
258
  Class.new(ActiveInteraction::Base) do
263
- VALID = [true, false].cycle
259
+ valid = [true, false].cycle
264
260
 
265
261
  validate do |interaction|
266
- interaction.errors.add(:base, 'failed') unless VALID.next
262
+ interaction.errors.add(:base, 'failed') unless valid.next
267
263
  end
268
264
 
269
265
  def execute
@@ -327,14 +323,14 @@ describe ActiveInteraction::Runnable do
327
323
  end
328
324
 
329
325
  context 'with failing composition' do
330
- class CheckInnerForFailure
326
+ class CheckInnerForFailure # rubocop:disable Lint/ConstantDefinitionInBlock
331
327
  include ActiveInteraction::Runnable
332
328
 
333
329
  attr_reader :caught_error
334
330
 
335
331
  def execute
336
332
  compose(WrappableFailingInteraction)
337
- rescue
333
+ rescue StandardError
338
334
  @caught_error = true
339
335
  raise
340
336
  end
@@ -345,6 +341,24 @@ describe ActiveInteraction::Runnable do
345
341
  expect(outcome.caught_error).to be true
346
342
  end
347
343
  end
344
+
345
+ context 'with block not called and error in execute around callback' do
346
+ class CheckExecuteAroundCallbackForFailure # rubocop:disable Lint/ConstantDefinitionInBlock
347
+ include ActiveInteraction::ActiveModelable
348
+ include ActiveInteraction::Runnable
349
+
350
+ set_callback :execute, :around, -> { errors.add(:base, 'invalid') }
351
+
352
+ def execute
353
+ true
354
+ end
355
+ end
356
+
357
+ it 'is invalid' do
358
+ outcome = CheckExecuteAroundCallbackForFailure.run
359
+ expect(outcome).to_not be_valid
360
+ end
361
+ end
348
362
  end
349
363
 
350
364
  describe '.run!' do