active_interaction 0.10.2 → 1.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/README.md +32 -35
  4. data/lib/active_interaction.rb +14 -6
  5. data/lib/active_interaction/base.rb +155 -135
  6. data/lib/active_interaction/concerns/active_modelable.rb +46 -0
  7. data/lib/active_interaction/{modules/overload_hash.rb → concerns/hashable.rb} +5 -1
  8. data/lib/active_interaction/concerns/missable.rb +47 -0
  9. data/lib/active_interaction/concerns/runnable.rb +156 -0
  10. data/lib/active_interaction/errors.rb +50 -14
  11. data/lib/active_interaction/filter.rb +33 -45
  12. data/lib/active_interaction/filters/abstract_date_time_filter.rb +24 -15
  13. data/lib/active_interaction/filters/abstract_filter.rb +18 -0
  14. data/lib/active_interaction/filters/abstract_numeric_filter.rb +13 -8
  15. data/lib/active_interaction/filters/array_filter.rb +42 -35
  16. data/lib/active_interaction/filters/boolean_filter.rb +8 -11
  17. data/lib/active_interaction/filters/date_filter.rb +11 -15
  18. data/lib/active_interaction/filters/date_time_filter.rb +11 -15
  19. data/lib/active_interaction/filters/file_filter.rb +11 -11
  20. data/lib/active_interaction/filters/float_filter.rb +7 -15
  21. data/lib/active_interaction/filters/hash_filter.rb +18 -24
  22. data/lib/active_interaction/filters/integer_filter.rb +7 -14
  23. data/lib/active_interaction/filters/model_filter.rb +13 -14
  24. data/lib/active_interaction/filters/string_filter.rb +11 -14
  25. data/lib/active_interaction/filters/symbol_filter.rb +6 -9
  26. data/lib/active_interaction/filters/time_filter.rb +13 -16
  27. data/lib/active_interaction/modules/validation.rb +9 -4
  28. data/lib/active_interaction/version.rb +5 -2
  29. data/spec/active_interaction/base_spec.rb +109 -4
  30. data/spec/active_interaction/{modules/active_model_spec.rb → concerns/active_modelable_spec.rb} +15 -3
  31. data/spec/active_interaction/{modules/overload_hash_spec.rb → concerns/hashable_spec.rb} +2 -3
  32. data/spec/active_interaction/{modules/method_missing_spec.rb → concerns/missable_spec.rb} +38 -3
  33. data/spec/active_interaction/concerns/runnable_spec.rb +192 -0
  34. data/spec/active_interaction/filters/abstract_filter_spec.rb +8 -0
  35. data/spec/active_interaction/filters/abstract_numeric_filter_spec.rb +1 -1
  36. data/spec/active_interaction/modules/validation_spec.rb +2 -2
  37. data/spec/support/concerns.rb +15 -0
  38. data/spec/support/filters.rb +5 -5
  39. data/spec/support/interactions.rb +6 -5
  40. metadata +47 -73
  41. data/lib/active_interaction/filters.rb +0 -28
  42. data/lib/active_interaction/modules/active_model.rb +0 -32
  43. data/lib/active_interaction/modules/core.rb +0 -70
  44. data/lib/active_interaction/modules/method_missing.rb +0 -20
  45. data/spec/active_interaction/filters_spec.rb +0 -23
  46. data/spec/active_interaction/modules/core_spec.rb +0 -114
@@ -0,0 +1,46 @@
1
+ # coding: utf-8
2
+
3
+ module ActiveInteraction
4
+ # Implement the minimal ActiveModel interface.
5
+ #
6
+ # @private
7
+ module ActiveModelable
8
+ extend ActiveSupport::Concern
9
+
10
+ include ActiveModel::Conversion
11
+ include ActiveModel::Validations
12
+
13
+ extend ActiveModel::Naming
14
+
15
+ # @return (see ClassMethods#i18n_scope)
16
+ #
17
+ # @see ActiveModel::Translation#i18n_scope
18
+ def i18n_scope
19
+ self.class.i18n_scope
20
+ end
21
+
22
+ # @return [Boolean]
23
+ #
24
+ # @see ActiveRecord::Presistence#new_record?
25
+ def new_record?
26
+ true
27
+ end
28
+
29
+ # @return [Boolean]
30
+ #
31
+ # @see ActiveRecord::Presistence#persisted?
32
+ def persisted?
33
+ false
34
+ end
35
+
36
+ #
37
+ module ClassMethods
38
+ # @return [Symbol]
39
+ #
40
+ # @see ActiveModel::Translation#i18n_scope
41
+ def i18n_scope
42
+ :active_interaction
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,8 +1,12 @@
1
1
  # coding: utf-8
2
2
 
3
3
  module ActiveInteraction
4
+ # Allow `hash` to be overridden when given arguments and/or a block.
5
+ #
4
6
  # @private
5
- module OverloadHash
7
+ module Hashable
8
+ extend ActiveSupport::Concern
9
+
6
10
  def hash(*args, &block)
7
11
  if args.empty? && !block_given?
8
12
  super
@@ -0,0 +1,47 @@
1
+ # coding: utf-8
2
+
3
+ module ActiveInteraction
4
+ # Handle common `method_missing` functionality.
5
+ #
6
+ # @private
7
+ module Missable
8
+ extend ActiveSupport::Concern
9
+
10
+ # @param slug [Symbol]
11
+ #
12
+ # @yield [klass, args, options]
13
+ #
14
+ # @yieldparam klass [Class]
15
+ # @yieldparam args [Array]
16
+ # @yieldparam options [Hash]
17
+ #
18
+ # @return [Missable]
19
+ def method_missing(slug, *args, &block)
20
+ return super unless (klass = filter(slug))
21
+
22
+ options = args.last.is_a?(Hash) ? args.pop : {}
23
+
24
+ yield(klass, args, options) if block_given?
25
+
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ # @param slug [Symbol]
32
+ #
33
+ # @return [Filter, nil]
34
+ def filter(slug)
35
+ Filter.factory(slug)
36
+ rescue MissingFilterError
37
+ nil
38
+ end
39
+
40
+ # @param slug [Symbol]
41
+ #
42
+ # @return [Boolean]
43
+ def respond_to_missing?(slug, *)
44
+ !!filter(slug)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,156 @@
1
+ # coding: utf-8
2
+
3
+ begin
4
+ require 'active_record'
5
+ rescue LoadError
6
+ module ActiveRecord
7
+ #
8
+ class Base
9
+ def self.transaction(*)
10
+ yield
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ module ActiveInteraction
17
+ # @abstract Include and override {#execute} to implement a custom Runnable
18
+ # class.
19
+ #
20
+ # @note Must be included after `ActiveModel::Validations`.
21
+ #
22
+ # Runs code in transactions and only provides the result if there are no
23
+ # validation errors.
24
+ #
25
+ # @private
26
+ module Runnable
27
+ extend ActiveSupport::Concern
28
+ include ActiveModel::Validations
29
+
30
+ included do
31
+ validate :runtime_errors
32
+ end
33
+
34
+ # @return [Errors]
35
+ def errors
36
+ @_interaction_errors
37
+ end
38
+
39
+ # @abstract
40
+ #
41
+ # @raise [NotImplementedError]
42
+ def execute
43
+ fail NotImplementedError
44
+ end
45
+
46
+ # @return [Object] If there are no validation errors.
47
+ # @return [nil] If there are validation errors.
48
+ def result
49
+ @_interaction_result
50
+ end
51
+
52
+ # @param result [Object]
53
+ #
54
+ # @return (see #result)
55
+ def result=(result)
56
+ if errors.empty?
57
+ @_interaction_result = result
58
+ @_interaction_runtime_errors = nil
59
+ else
60
+ @_interaction_result = nil
61
+ @_interaction_runtime_errors = errors.dup
62
+ end
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def valid?(*)
67
+ super || (self.result = nil)
68
+ end
69
+
70
+ private
71
+
72
+ # @param other [Class] The other interaction.
73
+ # @param (see ClassMethods.run)
74
+ #
75
+ # @return (see #result)
76
+ #
77
+ # @raise [Interrupt]
78
+ def compose(other, *args)
79
+ outcome = other.run(*args)
80
+
81
+ if outcome.valid?
82
+ outcome.result
83
+ else
84
+ fail Interrupt, outcome
85
+ end
86
+ end
87
+
88
+ # @return (see #result=)
89
+ # @return [nil]
90
+ def run
91
+ return unless valid?
92
+
93
+ self.result = ActiveRecord::Base.transaction do
94
+ begin
95
+ execute
96
+ rescue Interrupt => interrupt
97
+ interrupt.outcome.errors.full_messages.each do |message|
98
+ errors.add(:base, message) unless errors.added?(:base, message)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # @return [Object]
105
+ #
106
+ # @raise [InvalidInteractionError] If there are validation errors.
107
+ def run!
108
+ run
109
+
110
+ if valid?
111
+ result
112
+ else
113
+ fail InvalidInteractionError, errors.full_messages.join(', ')
114
+ end
115
+ end
116
+
117
+ # @!group Validations
118
+
119
+ def runtime_errors
120
+ if @_interaction_runtime_errors
121
+ errors.merge!(@_interaction_runtime_errors)
122
+ end
123
+ end
124
+
125
+ #
126
+ module ClassMethods
127
+ def new(*)
128
+ super.tap do |instance|
129
+ {
130
+ :@_interaction_errors => Errors.new(instance),
131
+ :@_interaction_result => nil,
132
+ :@_interaction_runtime_errors => nil
133
+ }.each do |symbol, obj|
134
+ instance.instance_variable_set(symbol, obj)
135
+ end
136
+ end
137
+ end
138
+
139
+ # @param (see Runnable#initialize)
140
+ #
141
+ # @return [Runnable]
142
+ def run(*args)
143
+ new(*args).tap { |instance| instance.send(:run) }
144
+ end
145
+
146
+ # @param (see Runnable#initialize)
147
+ #
148
+ # @return (see Runnable#run!)
149
+ #
150
+ # @raise (see Runnable#run!)
151
+ def run!(*args)
152
+ new(*args).send(:run!)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -1,63 +1,90 @@
1
1
  # coding: utf-8
2
2
 
3
- # rubocop:disable Documentation
3
+ #
4
4
  module ActiveInteraction
5
5
  # Top-level error class. All other errors subclass this.
6
+ #
7
+ # @return [Class]
6
8
  Error = Class.new(StandardError)
7
9
 
8
10
  # Raised if a class name is invalid.
11
+ #
12
+ # @return [Class]
9
13
  InvalidClassError = Class.new(Error)
10
14
 
11
15
  # Raised if a default value is invalid.
16
+ #
17
+ # @return [Class]
12
18
  InvalidDefaultError = Class.new(Error)
13
19
 
14
20
  # Raised if a filter has an invalid definition.
21
+ #
22
+ # @return [Class]
15
23
  InvalidFilterError = Class.new(Error)
16
24
 
17
25
  # Raised if an interaction is invalid.
26
+ #
27
+ # @return [Class]
18
28
  InvalidInteractionError = Class.new(Error)
19
29
 
20
30
  # Raised if a user-supplied value is invalid.
31
+ #
32
+ # @return [Class]
21
33
  InvalidValueError = Class.new(Error)
22
34
 
23
35
  # Raised if a filter cannot be found.
36
+ #
37
+ # @return [Class]
24
38
  MissingFilterError = Class.new(Error)
25
39
 
26
40
  # Raised if no value is given.
41
+ #
42
+ # @return [Class]
27
43
  MissingValueError = Class.new(Error)
28
44
 
29
45
  # Raised if there is no default value.
46
+ #
47
+ # @return [Class]
30
48
  NoDefaultError = Class.new(Error)
31
49
 
50
+ # Used by {Runnable} to signal a failure when composing.
51
+ #
32
52
  # @private
33
- Interrupt = Class.new(::Interrupt)
53
+ class Interrupt < Error
54
+ attr_reader :outcome
55
+
56
+ # @param outcome [Runnable]
57
+ def initialize(outcome)
58
+ super()
59
+
60
+ @outcome = outcome
61
+ end
62
+ end
34
63
  private_constant :Interrupt
35
64
 
36
- # A small extension to provide symbolic error messages to make introspecting
65
+ # An extension that provides symbolic error messages to make introspection
37
66
  # and testing easier.
38
- #
39
- # @since 0.6.0
40
67
  class Errors < ActiveModel::Errors
41
- # A hash mapping attributes to arrays of symbolic messages.
68
+ # Maps attributes to arrays of symbolic messages.
42
69
  #
43
70
  # @return [Hash{Symbol => Array<Symbol>}]
44
71
  attr_reader :symbolic
45
72
 
46
73
  # Adds a symbolic error message to an attribute.
47
74
  #
48
- # @param attribute [Symbol] The attribute to add an error to.
49
- # @param symbol [Symbol] The symbolic error to add.
50
- # @param message [String, Symbol, Proc]
51
- # @param options [Hash]
52
- #
53
- # @example Adding a symbolic error.
75
+ # @example
54
76
  # errors.add_sym(:attribute)
55
77
  # errors.symbolic
56
78
  # # => {:attribute=>[:invalid]}
57
79
  # errors.messages
58
80
  # # => {:attribute=>["is invalid"]}
59
81
  #
60
- # @return [Hash{Symbol => Array<Symbol>}]
82
+ # @param attribute [Symbol] The attribute to add an error to.
83
+ # @param symbol [Symbol, nil] The symbolic error to add.
84
+ # @param message [String, Symbol, Proc, nil] The message to add.
85
+ # @param options [Hash]
86
+ #
87
+ # @return (see #symbolic)
61
88
  #
62
89
  # @see ActiveModel::Errors#add
63
90
  def add_sym(attribute, symbol = :invalid, message = nil, options = {})
@@ -67,21 +94,30 @@ module ActiveInteraction
67
94
  symbolic[attribute] << symbol
68
95
  end
69
96
 
97
+ # @see ActiveModel::Errors#initialize
98
+ #
70
99
  # @private
71
- def initialize(*args)
100
+ def initialize(*)
72
101
  @symbolic = {}.with_indifferent_access
102
+
73
103
  super
74
104
  end
75
105
 
106
+ # @see ActiveModel::Errors#initialize_dup
107
+ #
76
108
  # @private
77
109
  def initialize_dup(other)
78
110
  @symbolic = other.symbolic.with_indifferent_access
111
+
79
112
  super
80
113
  end
81
114
 
115
+ # @see ActiveModel::Errors#clear
116
+ #
82
117
  # @private
83
118
  def clear
84
119
  symbolic.clear
120
+
85
121
  super
86
122
  end
87
123
 
@@ -4,15 +4,13 @@ require 'active_support/inflector'
4
4
 
5
5
  module ActiveInteraction
6
6
  # @!macro [new] filter_method_params
7
- # @param *attributes [Array<Symbol>] attributes to create
7
+ # @param *attributes [Array<Symbol>] Attributes to create.
8
8
  # @param options [Hash{Symbol => Object}]
9
9
  #
10
- # @option options [Object] :default fallback value if `nil` is given
11
- # @option options [String] :desc human-readable description of this input
10
+ # @option options [Object] :default Fallback value if `nil` is given.
11
+ # @option options [String] :desc Human-readable description of this input.
12
12
 
13
13
  # Describes an input filter for an interaction.
14
- #
15
- # @since 0.6.0
16
14
  class Filter
17
15
  # @return [Regexp]
18
16
  CLASS_REGEXP = /\AActiveInteraction::([A-Z]\w*)Filter\z/
@@ -22,7 +20,7 @@ module ActiveInteraction
22
20
  CLASSES = {}
23
21
  private_constant :CLASSES
24
22
 
25
- # @return [Filters]
23
+ # @return [Hash{Symbol => Filter}]
26
24
  attr_reader :filters
27
25
 
28
26
  # @return [Symbol]
@@ -31,10 +29,6 @@ module ActiveInteraction
31
29
  # @return [Hash{Symbol => Object}]
32
30
  attr_reader :options
33
31
 
34
- # Filters that allow sub-filters, like arrays and hashes, must be able to
35
- # use `hash` as a part of their DSL. To keep things consistent, `hash` is
36
- # undefined on all filters. Realistically, {#name} should be unique
37
- # enough to use in place of {#hash}.
38
32
  undef_method :hash
39
33
 
40
34
  class << self
@@ -43,7 +37,6 @@ module ActiveInteraction
43
37
  # @example
44
38
  # ActiveInteraction::Filter.factory(:boolean)
45
39
  # # => ActiveInteraction::BooleanFilter
46
- #
47
40
  # @example
48
41
  # ActiveInteraction::Filter.factory(:invalid)
49
42
  # # => ActiveInteraction::MissingFilterError: :invalid
@@ -52,7 +45,7 @@ module ActiveInteraction
52
45
  #
53
46
  # @return [Class]
54
47
  #
55
- # @raise [MissingFilterError] if the slug doesn't map to a filter
48
+ # @raise [MissingFilterError] If the slug doesn't map to a filter.
56
49
  #
57
50
  # @see .slug
58
51
  def factory(slug)
@@ -66,14 +59,13 @@ module ActiveInteraction
66
59
  # @example
67
60
  # ActiveInteraction::BooleanFilter.slug
68
61
  # # => :boolean
69
- #
70
62
  # @example
71
63
  # ActiveInteraction::Filter.slug
72
64
  # # => ActiveInteraction::InvalidClassError: ActiveInteraction::Filter
73
65
  #
74
66
  # @return [Symbol]
75
67
  #
76
- # @raise [InvalidClassError] if the filter doesn't have a valid slug
68
+ # @raise [InvalidClassError] If the filter doesn't have a valid slug.
77
69
  #
78
70
  # @see .factory
79
71
  def slug
@@ -82,6 +74,8 @@ module ActiveInteraction
82
74
  match.captures.first.underscore.to_sym
83
75
  end
84
76
 
77
+ # @param klass [Class]
78
+ #
85
79
  # @private
86
80
  def inherited(klass)
87
81
  CLASSES[klass.slug] = klass
@@ -93,11 +87,11 @@ module ActiveInteraction
93
87
  # @param name [Symbol]
94
88
  # @param options [Hash{Symbol => Object}]
95
89
  #
96
- # @option options [Object] :default fallback value to use if `nil` is given
90
+ # @option options [Object] :default Fallback value to use when given `nil`.
97
91
  def initialize(name, options = {}, &block)
98
92
  @name = name
99
93
  @options = options.dup
100
- @filters = Filters.new
94
+ @filters = {}
101
95
 
102
96
  instance_eval(&block) if block_given?
103
97
  end
@@ -108,29 +102,22 @@ module ActiveInteraction
108
102
  # @example
109
103
  # ActiveInteraction::Filter.new(:example).clean(nil)
110
104
  # # => ActiveInteraction::MissingValueError: example
111
- #
112
105
  # @example
113
106
  # ActiveInteraction::Filter.new(:example).clean(0)
114
107
  # # => ActiveInteraction::InvalidValueError: example: 0
115
- #
116
108
  # @example
117
109
  # ActiveInteraction::Filter.new(:example, default: nil).clean(nil)
118
110
  # # => nil
119
- #
120
111
  # @example
121
112
  # ActiveInteraction::Filter.new(:example, default: 0).clean(nil)
122
- # # => ActiveInteraction::InvalidDefault: example: 0
113
+ # # => ActiveInteraction::InvalidDefaultError: example: 0
123
114
  #
124
115
  # @param value [Object]
125
116
  #
126
117
  # @return [Object]
127
118
  #
128
- # @raise [InvalidValueError] if the value is invalid
129
- # @raise [MissingValueError] if the value is missing and the input is
130
- # required
119
+ # @raise (see #cast)
131
120
  # @raise (see #default)
132
- #
133
- # @see #default
134
121
  def clean(value)
135
122
  value = cast(value)
136
123
  if value.nil?
@@ -143,23 +130,21 @@ module ActiveInteraction
143
130
  # Get the default value.
144
131
  #
145
132
  # @example
133
+ # ActiveInteraction::Filter.new(:example).default
134
+ # # => ActiveInteraction::NoDefaultError: example
135
+ # @example
146
136
  # ActiveInteraction::Filter.new(:example, default: nil).default
147
137
  # # => nil
148
- #
149
138
  # @example
150
139
  # ActiveInteraction::Filter.new(:example, default: 0).default
151
140
  # # => ActiveInteraction::InvalidDefaultError: example: 0
152
141
  #
153
- # @example
154
- # ActiveInteraction::Filter.new(:example).default
155
- # # => ActiveInteraction::NoDefaultError: example
156
- #
157
142
  # @return [Object]
158
143
  #
159
- # @raise [InvalidDefaultError] if the default value is invalid
160
- # @raise [NoDefaultError] if there is no default value
144
+ # @raise [NoDefaultError] If the default is missing.
145
+ # @raise [InvalidDefaultError] If the default is invalid.
161
146
  def default
162
- fail NoDefaultError, name unless has_default?
147
+ fail NoDefaultError, name unless default?
163
148
 
164
149
  cast(options[:default])
165
150
  rescue InvalidValueError, MissingValueError
@@ -169,12 +154,10 @@ module ActiveInteraction
169
154
  # Get the description.
170
155
  #
171
156
  # @example
172
- # ActiveInteraction::Filter.new(:example, desc: 'description').desc
173
- # # => "description"
174
- #
175
- # @return [String, nil] the description
157
+ # ActiveInteraction::Filter.new(:example, desc: 'Description!').desc
158
+ # # => "Description!"
176
159
  #
177
- # @since 0.8.0
160
+ # @return [String, nil]
178
161
  def desc
179
162
  options[:desc]
180
163
  end
@@ -182,25 +165,30 @@ module ActiveInteraction
182
165
  # Tells if this filter has a default value.
183
166
  #
184
167
  # @example
185
- # filter = ActiveInteraction::Filter.new(:example)
186
- # filter.has_default?
168
+ # ActiveInteraction::Filter.new(:example).default?
187
169
  # # => false
188
- #
189
170
  # @example
190
- # filter = ActiveInteraction::Filter.new(:example, default: nil)
191
- # filter.has_default?
171
+ # ActiveInteraction::Filter.new(:example, default: nil).default?
192
172
  # # => true
193
173
  #
194
174
  # @return [Boolean]
195
- def has_default?
175
+ def default?
196
176
  options.key?(:default)
197
177
  end
198
178
 
179
+ # @param value [Object]
180
+ #
181
+ # @return [Object]
182
+ #
183
+ # @raise [MissingValueError] If the value is missing and there is no
184
+ # default.
185
+ # @raise [InvalidValueError] If the value is invalid.
186
+ #
199
187
  # @private
200
188
  def cast(value)
201
189
  case value
202
190
  when NilClass
203
- fail MissingValueError, name unless has_default?
191
+ fail MissingValueError, name unless default?
204
192
 
205
193
  nil
206
194
  else