active_interaction 3.8.0 → 4.0.1

Sign up to get free protection for your applications and to get access to all the features.
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