active_interaction 0.10.2 → 1.0.0

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