subroutine 1.0.0.rc2 → 2.0.0.beta

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83e75f941dcb1020be24cefa722acc6a2fa5689479eac294e50cdfab09a8ea97
4
- data.tar.gz: c7bbe661699afcbfd8ffe6b3da0ca06dd21ea6dd9c29e80265ad507361754483
3
+ metadata.gz: d0ade77ec5abb9601e1dce7bc53e34baac35c978a41aef3268b57ee5b9def6e5
4
+ data.tar.gz: c450947886efc34b26b202b52202909f44735a2d5f26a3c1af51fdf93be509c6
5
5
  SHA512:
6
- metadata.gz: 26f93744cee2306723ca1d4949b0cc1d85570cd761ac6254edc50b58ed26d884b81e3a53e478818072077177861d74b5f806527d03ca6716c9e6eba3575b1d8f
7
- data.tar.gz: 157b2503fba1c1866a4bda9e13b04311acf2dd87caf163010025f6495f08305bf63859bbb3d810c3a789bfbb753c0f9bc03f21518805158d79c5490bc66a8ad6
6
+ metadata.gz: c171a9bb82076ed4f5e407375d19e423a5e6bba0211192109d300e3b7372d537a0bac3d67ef5c480ef9af34ddd2a849a07fef79459f5597ea97e195cad239fe7
7
+ data.tar.gz: 52502e8dd504de2d59faa4665ab7c755c9ff6ba8b951e79acaa264c4d257f09a20da58977f1565e8d121023d6a2f5291cd4528eca143d697252b2f6a27d4197b
data/CHANGELOG.MD ADDED
@@ -0,0 +1,111 @@
1
+ ## Subroutine 1.0
2
+
3
+ A massive refactor took place between 0.9 and 1.0, including breaking changes. The goal was to reduce complexity, simplify backtraces, and increase the overall safety and reliability of the library.
4
+
5
+ ### Subroutine::Fields
6
+
7
+ `Subroutine::Fields` was completely refactored to manage field declaration, configuration, and access in a more systematic and safer way.
8
+
9
+ `Op._fields` was removed in favor of `Op.field_configurations`. `field_configurations` is a hash with keys of the field name and values of `FieldConfiguration` objects. FieldConfiguration objects are SimpleDelegates to the underlying option hashes. They answer questions and provide a mechanism for validating the configuration of a field.
10
+
11
+ Fields can be accessed via helpers and accessors can be managed by the field declration. Helpers include `get_field`, `set_field`, and `clear_field`.
12
+
13
+ ```ruby
14
+ class SomeOp < ::Subroutine::Op
15
+ string :foo, read_accessor: field_reader: false, field_writer: true
16
+
17
+ def perform
18
+ self.foo = "bar"
19
+ self.foo # NoMethodError
20
+ self.get_field(:foo) # => "bar"
21
+ end
22
+ end
23
+ ```
24
+
25
+ Fields can be omitted from mass assignment, meaning they would not be respected via constructor signatures.
26
+
27
+ ```ruby
28
+ class SomeOp < ::Subroutine::Op
29
+ string :foo, mass_assignable: false
30
+ def perform
31
+ puts foo
32
+ end
33
+ end
34
+
35
+ SomeOp.submit!(foo: "Hello World!") # raises ::Subroutine::Fields::MassAssignmentError
36
+ SomeOp.new{|op| op.foo = "Hello World!" }.submit! # prints "Hello World!"
37
+ ```
38
+
39
+ This is especially useful when dealing with user input and potentially unsafe attributes.
40
+
41
+ ```ruby
42
+ class UserUpdateOp < ::Op
43
+ association :user
44
+ string :first_name
45
+ string :last_name
46
+ integer :credit_balance_cents, mass_assignable: false
47
+
48
+ def perform
49
+ user.update(params)
50
+ end
51
+ end
52
+
53
+ # some_controller.rb
54
+ def update
55
+ UserUpdateOp.submit!(params.merge(user: current_user))
56
+ end
57
+ ```
58
+
59
+ Field groups were added as well, allowing you to access subsets of the fields easily.
60
+
61
+ ```ruby
62
+ class AccountUpdateOp < ::Op
63
+ association :account
64
+
65
+ with_options group: :user do
66
+ string :first_name
67
+ string :last_name
68
+ date :dob
69
+ end
70
+
71
+ with_options group: :business do
72
+ string :company_name
73
+ string :ein
74
+ end
75
+
76
+ def perform
77
+ account.user.update(user_params)
78
+ account.business.update(business_params)
79
+ end
80
+
81
+ end
82
+ ```
83
+
84
+ ActionController::Parameters from Rails 5+ are now transformed to as hash in `Subroutine::Fields` by default. This means strong parameters are essentially unused when passing `Subroutine::Fields`.
85
+
86
+ Read more about field management and access in https://github.com/guideline-tech/subroutine/wiki/Param-Usage
87
+
88
+ ### Subroutine::Association
89
+
90
+ The `Subroutine::Association` module has been moved to `Subroutine::AssociationFields`.
91
+
92
+ Only native types are stored in params now. The objects loaded from associations are stored in an `association_cache`. This ensures access to fields are consistent regardless of the inputs.
93
+
94
+ ```ruby
95
+ class SomeOp < ::Subroutine::Op
96
+ association :user
97
+ association :resource, polymorphic: true
98
+ end
99
+
100
+ user = User.find(4)
101
+
102
+ op = SomeOp.new(user: user, resource: user)
103
+ op.params #=> { user_id: 4, resource_type: "User", resource_id: 4 }
104
+ op.params_with_association #=> { user: <User:103204 @id=4>, resource: <User:103204 @id=4> }
105
+
106
+ op = SomeOp.new(user_id: user.id, resource_type: "User", resource_id: user.id)
107
+ op.params #=> { user_id: 4, resource_type: "User", resource_id: 4 }
108
+ op.params_with_association #=> { user: <User:290053 @id=4>, resource: <User:29042 @id=4> }
109
+ ```
110
+
111
+ Assignment of associations now validates the type. If an association is not polymorphic, the type will be validated against the expected type.
data/Gemfile CHANGED
@@ -5,4 +5,4 @@ source "https://rubygems.org"
5
5
  # Specify your gem's dependencies in subroutine.gemspec
6
6
  gemspec
7
7
 
8
- gem "activemodel", "~> 4.2.11"
8
+ eval_gemfile "gemfiles/am60.gemfile"
data/README.md CHANGED
@@ -13,26 +13,37 @@ class SignupOp < ::Subroutine::Op
13
13
  string :email
14
14
  string :password
15
15
 
16
+ string :company_name
17
+
16
18
  validates :name, presence: true
17
19
  validates :email, presence: true
18
20
  validates :password, presence: true
21
+ validates :company_name, presence: true
19
22
 
20
- outputs :signed_up_user
23
+ outputs :user
24
+ outputs :business
21
25
 
22
26
  protected
23
27
 
24
28
  def perform
25
29
  u = create_user!
26
- deliver_welcome_email!(u)
30
+ b = create_business!(u)
31
+
32
+ deliver_welcome_email(u)
27
33
 
28
- output :signed_up_user, u
34
+ output :user, u
35
+ output :business, b
29
36
  end
30
37
 
31
38
  def create_user!
32
- User.create!(params)
39
+ User.create!(name: name, email: email, password: password)
33
40
  end
34
41
 
35
- def deliver_welcome_email!(u)
42
+ def create_business!(owner)
43
+ Business.create!(company_name: company_name, owner: owner)
44
+ end
45
+
46
+ def deliver_welcome_email(u)
36
47
  UserMailer.welcome(u.id).deliver_later
37
48
  end
38
49
  end
@@ -45,7 +56,9 @@ end
45
56
  - Clear and concise intention in a single file
46
57
  - Multi-model operations become simple
47
58
 
48
- [Implementing an Op](https://github.com/guideline-tech/subroutine/wiki/Implementing-an-Op)
49
- [Using an Op](https://github.com/guideline-tech/subroutine/wiki/Using-an-Op)
50
- [Errors](https://github.com/guideline-tech/subroutine/wiki/Errors)
51
- [Basic Usage in Rails](https://github.com/guideline-tech/subroutine/wiki/Rails-Usage)
59
+ ## Continue Reading
60
+
61
+ - [Implementing an Op](https://github.com/guideline-tech/subroutine/wiki/Implementing-an-Op)
62
+ - [Using an Op](https://github.com/guideline-tech/subroutine/wiki/Using-an-Op)
63
+ - [Errors](https://github.com/guideline-tech/subroutine/wiki/Errors)
64
+ - [Basic Usage in Rails](https://github.com/guideline-tech/subroutine/wiki/Rails-Usage)
@@ -2,4 +2,6 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
- gem 'activemodel', '~> 4.1.9'
5
+ gem 'rack'
6
+ gem 'activemodel', '~> 4.1.0'
7
+ gem 'actionpack', '~> 4.1.0'
@@ -2,4 +2,6 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
+ gem 'rack'
5
6
  gem 'activemodel', '~> 4.2.0'
7
+ gem 'actionpack', '~> 4.2.0'
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
- gem 'activemodel', '~> 5.0.7.2'
5
+ gem 'activemodel', '~> 5.0.0'
6
+ gem 'actionpack', '~> 5.0.0'
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
- gem 'activemodel', '~> 5.1.7'
5
+ gem 'activemodel', '~> 5.1.0'
6
+ gem 'actionpack', '~> 5.1.0'
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
- gem 'activemodel', '~> 5.2.3'
5
+ gem 'activemodel', '~> 5.2.0'
6
+ gem 'actionpack', '~> 5.2.0'
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  gemspec :path => '../'
4
4
 
5
- gem 'activemodel', '~> 6.0.0.rc1'
5
+ gem 'activemodel', '~> 6.0.0'
6
+ gem 'actionpack', '~> 6.0.0'
@@ -69,16 +69,17 @@ module Subroutine
69
69
  config = ::Subroutine::AssociationFields::Configuration.new(field_name, options)
70
70
 
71
71
  if config.polymorphic?
72
- string config.foreign_type_method, config.build_foreign_type_field
72
+ field config.foreign_type_method, config.build_foreign_type_field
73
73
  else
74
74
  class_eval <<-EV, __FILE__, __LINE__ + 1
75
+ try(:silence_redefinition_of_method, :#{config.foreign_type_method})
75
76
  def #{config.foreign_type_method}
76
- #{config.inferred_class_name.inspect}
77
+ #{config.inferred_foreign_type.inspect}
77
78
  end
78
79
  EV
79
80
  end
80
81
 
81
- integer config.foreign_key_method, config.build_foreign_key_field
82
+ field config.foreign_key_method, config.build_foreign_key_field
82
83
 
83
84
  field_without_association(config.as, config)
84
85
  else
@@ -99,13 +100,14 @@ module Subroutine
99
100
 
100
101
  excepts = []
101
102
  association_fields.each_pair do |_name, config|
102
- excepts << config.foreign_key_method
103
- excepts << config.foreign_type_method if config.polymorphic?
103
+ excepts |= config.related_field_names
104
104
  end
105
105
 
106
106
  out = params.except(*excepts)
107
- association_fields.each_pair do |field_name, _config|
108
- out[field_name] = get_field(field_name) if field_provided?(field_name)
107
+ association_fields.each_pair do |field_name, config|
108
+ next unless field_provided?(field_name)
109
+
110
+ out[field_name] = config.field_reader? ? send(field_name) : get_field(field_name)
109
111
  end
110
112
 
111
113
  out
@@ -117,7 +119,7 @@ module Subroutine
117
119
  if config&.behavior == :association
118
120
  maybe_raise_on_association_type_mismatch!(config, value)
119
121
  set_field(config.foreign_type_method, value&.class&.name, opts) if config.polymorphic?
120
- set_field(config.foreign_key_method, value&.id, opts)
122
+ set_field(config.foreign_key_method, value&.send(config.find_by), opts)
121
123
  association_cache[config.field_name] = value
122
124
  else
123
125
  if config&.behavior == :association_component
@@ -135,10 +137,7 @@ module Subroutine
135
137
  stored_result = association_cache[config.field_name]
136
138
  return stored_result unless stored_result.nil?
137
139
 
138
- fk = get_field(config.foreign_key_method)
139
- type = config.polymorphic? ? get_field(config.foreign_type_method) : send(config.foreign_type_method)
140
-
141
- result = fetch_association_instance(type, fk, config.unscoped?)
140
+ result = fetch_association_instance(config)
142
141
  association_cache[config.field_name] = result
143
142
  else
144
143
  get_field_without_association(field_name)
@@ -173,25 +172,32 @@ module Subroutine
173
172
  end
174
173
  end
175
174
 
176
- def fetch_association_instance(_type, _fk, _unscoped = false)
177
- return nil unless _type && _fk
175
+ def fetch_association_instance(config)
176
+ klass =
177
+ if config.field_reader?
178
+ config.polymorphic? ? send(config.foreign_type_method) : config.inferred_foreign_type
179
+ else
180
+ get_field(config.foreign_type_method)
181
+ end
178
182
 
179
- klass = _type
180
183
  klass = klass.classify.constantize if klass.is_a?(String)
181
-
182
184
  return nil unless klass
183
185
 
186
+ foreign_key = config.foreign_key_method
187
+ value = send(foreign_key)
188
+ return nil unless value
189
+
184
190
  scope = klass.all
185
- scope = scope.unscoped if _unscoped
191
+ scope = scope.unscoped if config.unscoped?
186
192
 
187
- scope.find(_fk)
193
+ scope.find_by!(config.find_by => value)
188
194
  end
189
195
 
190
196
  def maybe_raise_on_association_type_mismatch!(config, record)
191
197
  return if config.polymorphic?
192
198
  return if record.nil?
193
199
 
194
- klass = config.inferred_class_name.constantize
200
+ klass = config.inferred_foreign_type.constantize
195
201
 
196
202
  return if record.class <= klass || record.class >= klass
197
203
 
@@ -10,7 +10,7 @@ module Subroutine
10
10
  def validate!
11
11
  super
12
12
 
13
- if as && foreign_key
13
+ if config[:as] && foreign_key
14
14
  raise ArgumentError, ":as and :foreign_key options should not be provided together to an association invocation"
15
15
  end
16
16
  end
@@ -19,6 +19,13 @@ module Subroutine
19
19
  super + [::Subroutine::AssociationFields]
20
20
  end
21
21
 
22
+ def related_field_names
23
+ out = super
24
+ out << foreign_key_method
25
+ out << foreign_type_method if polymorphic?
26
+ out
27
+ end
28
+
22
29
  def polymorphic?
23
30
  !!config[:polymorphic]
24
31
  end
@@ -27,12 +34,13 @@ module Subroutine
27
34
  config[:as] || field_name
28
35
  end
29
36
 
30
- def class_name
31
- config[:class_name]&.to_s
37
+ def foreign_type
38
+ (config[:foreign_type] || config[:class_name])&.to_s
32
39
  end
40
+ alias class_name foreign_type
33
41
 
34
- def inferred_class_name
35
- class_name || as.to_s.camelize
42
+ def inferred_foreign_type
43
+ foreign_type || as.to_s.camelize
36
44
  end
37
45
 
38
46
  def foreign_key
@@ -40,11 +48,15 @@ module Subroutine
40
48
  end
41
49
 
42
50
  def foreign_key_method
43
- foreign_key || "#{field_name}_id"
51
+ (foreign_key || "#{field_name}_id").to_sym
44
52
  end
45
53
 
46
54
  def foreign_type_method
47
- foreign_key_method.gsub(/_id$/, "_type")
55
+ foreign_key_method.to_s.gsub(/_id$/, "_type").to_sym
56
+ end
57
+
58
+ def find_by
59
+ (config[:find_by] || :id).to_sym
48
60
  end
49
61
 
50
62
  def build_foreign_key_field
@@ -52,7 +64,7 @@ module Subroutine
52
64
  end
53
65
 
54
66
  def build_foreign_type_field
55
- build_child_field(foreign_type_method)
67
+ build_child_field(foreign_type_method, type: :string)
56
68
  end
57
69
 
58
70
  def unscoped?
@@ -65,8 +77,8 @@ module Subroutine
65
77
 
66
78
  protected
67
79
 
68
- def build_child_field(name)
69
- ComponentConfiguration.new(name, inheritable_options.merge(association_name: as))
80
+ def build_child_field(name, opts = {})
81
+ ComponentConfiguration.new(name, inheritable_options.merge(opts).merge(association_name: as))
70
82
  end
71
83
 
72
84
  end
@@ -85,17 +85,24 @@ module Subroutine
85
85
  def initialize(*args, &block)
86
86
  raise Subroutine::Auth::AuthorizationNotDeclaredError unless self.class.authorization_declared
87
87
 
88
- super(args.extract_options!, &block)
89
-
90
88
  @skip_auth_checks = false
91
89
 
90
+ inputs = case args.last
91
+ when *::Subroutine::Fields.allowed_input_classes
92
+ args.pop
93
+ else
94
+ {}
95
+ end
96
+
97
+ super(inputs, &block)
98
+
92
99
  user = args.shift
93
100
 
94
- if self.class.supported_user_class_names.include?(user.class.name)
95
- @current_user = user
96
- else
101
+ unless self.class.supported_user_class_names.include?(user.class.name)
97
102
  raise ArgumentError, "current_user must be one of the following types {#{self.class.supported_user_class_names.join(",")}} but was #{user.class.name}"
98
103
  end
104
+
105
+ @current_user = user
99
106
  end
100
107
 
101
108
  def skip_auth_checks!
@@ -14,6 +14,18 @@ module Subroutine
14
14
 
15
15
  extend ActiveSupport::Concern
16
16
 
17
+ def self.allowed_input_classes
18
+ @allowed_input_classes ||= begin
19
+ out = [Hash]
20
+ out << ActionController::Parameters if action_controller_params_loaded?
21
+ out
22
+ end
23
+ end
24
+
25
+ def self.action_controller_params_loaded?
26
+ defined?(::ActionController::Parameters)
27
+ end
28
+
17
29
  included do
18
30
  class_attribute :field_configurations
19
31
  self.field_configurations = {}
@@ -46,9 +58,12 @@ module Subroutine
46
58
  onlys = options.key?(:only) ? Array(options.delete(:only)) : nil
47
59
 
48
60
  things.each do |thing|
61
+ local_excepts = excepts.map { |field| thing.get_field_config(field)&.related_field_names }.flatten.compact.uniq if excepts
62
+ local_onlys = onlys.map { |field| thing.get_field_config(field)&.related_field_names }.flatten.compact.uniq if onlys
63
+
49
64
  thing.field_configurations.each_pair do |field_name, config|
50
- next if excepts&.include?(field_name)
51
- next if onlys && !onlys.include?(field_name)
65
+ next if local_excepts&.include?(field_name)
66
+ next if local_onlys && !local_onlys.include?(field_name)
52
67
 
53
68
  config.required_modules.each do |mod|
54
69
  include mod unless included_modules.include?(mod)
@@ -119,16 +134,18 @@ module Subroutine
119
134
  end
120
135
 
121
136
  def ensure_field_accessors(config)
122
- if config.field_writer? && !instance_methods.include?(:"#{config.field_name}=")
137
+ if config.field_writer?
123
138
  class_eval <<-EV, __FILE__, __LINE__ + 1
139
+ try(:silence_redefinition_of_method, :#{config.field_name}=)
124
140
  def #{config.field_name}=(v)
125
141
  set_field(:#{config.field_name}, v)
126
142
  end
127
143
  EV
128
144
  end
129
145
 
130
- if config.field_reader? && !instance_methods.include?(:"#{config.field_name}")
146
+ if config.field_reader?
131
147
  class_eval <<-EV, __FILE__, __LINE__ + 1
148
+ try(:silence_redefinition_of_method, :#{config.field_name})
132
149
  def #{config.field_name}
133
150
  get_field(:#{config.field_name})
134
151
  end
@@ -139,6 +156,9 @@ module Subroutine
139
156
  end
140
157
 
141
158
  def setup_fields(inputs = {})
159
+ if ::Subroutine::Fields.action_controller_params_loaded? && inputs.is_a?(::ActionController::Parameters)
160
+ inputs = inputs.to_unsafe_h if inputs.respond_to?(:to_unsafe_h)
161
+ end
142
162
  @provided_fields = {}.with_indifferent_access
143
163
  param_groups[:original] = inputs.with_indifferent_access
144
164
  mass_assign_initial_params
@@ -7,7 +7,7 @@ module Subroutine
7
7
  class Configuration < ::SimpleDelegator
8
8
 
9
9
  PROTECTED_GROUP_IDENTIFIERS = %i[all original default].freeze
10
- INHERITABLE_OPTIONS = %i[mass_assignable field_reader field_writer groups].freeze
10
+ INHERITABLE_OPTIONS = %i[mass_assignable field_reader field_writer groups aka].freeze
11
11
  NO_GROUPS = [].freeze
12
12
 
13
13
  def self.from(field_name, options)
@@ -22,7 +22,7 @@ module Subroutine
22
22
  attr_reader :field_name
23
23
 
24
24
  def initialize(field_name, options)
25
- @field_name = field_name
25
+ @field_name = field_name.to_sym
26
26
  config = sanitize_options(options)
27
27
  super(config)
28
28
  validate!
@@ -38,6 +38,10 @@ module Subroutine
38
38
  []
39
39
  end
40
40
 
41
+ def related_field_names
42
+ [field_name]
43
+ end
44
+
41
45
  def behavior
42
46
  nil
43
47
  end
@@ -96,6 +100,7 @@ module Subroutine
96
100
  groups = nil if groups == false
97
101
  opts[:groups] = Array(groups).map(&:to_sym).presence
98
102
  opts.delete(:group)
103
+ opts[:aka] = opts[:aka].to_sym if opts[:aka]
99
104
  opts
100
105
  end
101
106
 
data/lib/subroutine/op.rb CHANGED
@@ -37,28 +37,11 @@ module Subroutine
37
37
  op
38
38
  end
39
39
 
40
- protected
41
-
42
- def field(field_name, options = {})
43
- result = super(field_name, options)
44
-
45
- if options[:aka]
46
- Array(options[:aka]).each do |as|
47
- self._error_map = _error_map.merge(as.to_sym => field_name.to_sym)
48
- end
49
- end
50
-
51
- result
52
- end
53
-
54
40
  end
55
41
 
56
42
  class_attribute :_failure_class
57
43
  self._failure_class = Subroutine::Failure
58
44
 
59
- class_attribute :_error_map
60
- self._error_map = {}
61
-
62
45
  def initialize(inputs = {})
63
46
  setup_fields(inputs)
64
47
  setup_outputs
@@ -82,7 +65,7 @@ module Subroutine
82
65
 
83
66
  if errors.empty?
84
67
  validate_outputs!
85
- true
68
+ self
86
69
  else
87
70
  raise _failure_class, self
88
71
  end
@@ -91,6 +74,7 @@ module Subroutine
91
74
  # the action which should be invoked upon form submission (from the controller)
92
75
  def submit
93
76
  submit!
77
+ true
94
78
  rescue Exception => e
95
79
  if e.respond_to?(:record)
96
80
  inherit_errors(e.record) unless e.record == self
@@ -127,18 +111,23 @@ module Subroutine
127
111
  raise NotImplementedError
128
112
  end
129
113
 
130
- # applies the errors in error_object to self
131
- # returns false so failure cases can end with this invocation
132
- def inherit_errors(error_object)
114
+ def inherit_errors(error_object, prefix: nil)
133
115
  error_object = error_object.errors if error_object.respond_to?(:errors)
134
116
 
135
- error_object.each do |k, v|
136
- if respond_to?(k)
137
- errors.add(k, v)
138
- elsif _error_map[k.to_sym]
139
- errors.add(_error_map[k.to_sym], v)
117
+ error_object.each do |field_name, error|
118
+ field_name = "#{prefix}#{field_name}" if prefix
119
+ field_name = field_name.to_sym
120
+
121
+ field_config = get_field_config(field_name)
122
+ field_config ||= begin
123
+ kv = field_configurations.find { |_k, config| config[:aka] == field_name }
124
+ kv ? kv.last : nil
125
+ end
126
+
127
+ if field_config
128
+ errors.add(field_config.field_name, error)
140
129
  else
141
- errors.add(:base, error_object.full_message(k, v))
130
+ errors.add(:base, error_object.full_message(field_name, error))
142
131
  end
143
132
  end
144
133
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module Subroutine
4
4
 
5
- MAJOR = 1
5
+ MAJOR = 2
6
6
  MINOR = 0
7
7
  PATCH = 0
8
- PRE = "rc2"
8
+ PRE = "beta"
9
9
 
10
10
  VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join(".")
11
11
 
data/subroutine.gemspec CHANGED
@@ -1,15 +1,16 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'subroutine/version'
5
+ require "subroutine/version"
5
6
 
6
7
  Gem::Specification.new do |spec|
7
8
  spec.name = "subroutine"
8
9
  spec.version = Subroutine::VERSION
9
10
  spec.authors = ["Mike Nelson"]
10
11
  spec.email = ["mike@mnelson.io"]
11
- spec.summary = %q{Feature-driven operation objects.}
12
- spec.description = %q{An interface for creating feature-driven operations.}
12
+ spec.summary = "Feature-driven operation objects."
13
+ spec.description = "An interface for creating feature-driven operations."
13
14
  spec.homepage = "https://github.com/mnelson/subroutine"
14
15
  spec.license = "MIT"
15
16
 
@@ -19,12 +20,14 @@ Gem::Specification.new do |spec|
19
20
  spec.require_paths = ["lib"]
20
21
 
21
22
  spec.add_dependency "activemodel", ">= 4.0.0"
23
+ spec.add_dependency "activesupport", ">= 4.0.0"
22
24
 
25
+ spec.add_development_dependency "actionpack", ">= 4.0"
23
26
  spec.add_development_dependency "bundler"
24
- spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "byebug"
25
28
  spec.add_development_dependency "m"
26
29
  spec.add_development_dependency "minitest"
27
30
  spec.add_development_dependency "minitest-reporters"
28
31
  spec.add_development_dependency "mocha"
29
- spec.add_development_dependency "byebug"
32
+ spec.add_development_dependency "rake"
30
33
  end
@@ -23,11 +23,21 @@ module Subroutine
23
23
  assert_equal doug.id, op.user_id
24
24
  end
25
25
 
26
+ def test_it_can_be_nil
27
+ op = SimpleAssociationOp.new user: nil
28
+ assert_nil op.user
29
+ assert_nil op.user_id
30
+
31
+ op = SimpleAssociationOp.new
32
+ assert_nil op.user
33
+ assert_nil op.user_id
34
+ end
35
+
26
36
  def test_it_looks_up_an_association
27
37
  all_mock = mock
28
38
 
29
39
  ::User.expects(:all).returns(all_mock)
30
- all_mock.expects(:find).with(1).returns(doug)
40
+ all_mock.expects(:find_by!).with(id: 1).returns(doug)
31
41
 
32
42
  op = SimpleAssociationOp.new user_type: "User", user_id: doug.id
33
43
  assert_equal doug, op.user
@@ -37,7 +47,7 @@ module Subroutine
37
47
  all_mock = mock
38
48
 
39
49
  ::User.expects(:all).returns(all_mock)
40
- all_mock.expects(:find).with(1).returns(doug)
50
+ all_mock.expects(:find_by!).with(id: 1).returns(doug)
41
51
 
42
52
  op = SimpleAssociationOp.new user_id: doug.id
43
53
  assert_equal doug, op.user
@@ -49,7 +59,7 @@ module Subroutine
49
59
 
50
60
  ::User.expects(:all).returns(all_mock)
51
61
  all_mock.expects(:unscoped).returns(unscoped_mock)
52
- unscoped_mock.expects(:find).with(1).returns(doug)
62
+ unscoped_mock.expects(:find_by!).with(id: 1).returns(doug)
53
63
 
54
64
  op = UnscopedSimpleAssociationOp.new user_id: doug.id
55
65
  assert_equal doug, op.user
@@ -59,7 +69,7 @@ module Subroutine
59
69
  all_mock = mock
60
70
  ::User.expects(:all).never
61
71
  ::AdminUser.expects(:all).returns(all_mock)
62
- all_mock.expects(:find).with(1).returns(doug)
72
+ all_mock.expects(:find_by!).with(id: 1).returns(doug)
63
73
 
64
74
  op = PolymorphicAssociationOp.new(admin_type: "AdminUser", admin_id: doug.id)
65
75
  assert_equal doug, op.admin
@@ -70,11 +80,60 @@ module Subroutine
70
80
  assert_equal "AdminUser", op.admin_type
71
81
  end
72
82
 
83
+ def test_it_allows_foreign_key_to_be_set
84
+ all_mock = mock
85
+ ::User.expects(:all).returns(all_mock)
86
+ all_mock.expects(:find_by!).with(id: "foobarbaz").returns(doug)
87
+
88
+ op = ::AssociationWithForeignKeyOp.new(user_identifier: "foobarbaz")
89
+ assert_equal doug, op.user
90
+ assert_equal "user_identifier", op.field_configurations[:user][:foreign_key]
91
+ end
92
+
93
+ def test_it_allows_a_foreign_key_and_find_by_to_be_set
94
+ all_mock = mock
95
+ ::User.expects(:all).returns(all_mock)
96
+ all_mock.expects(:find_by!).with(email_address: doug.email_address).returns(doug)
97
+
98
+ op = ::AssociationWithFindByAndForeignKeyOp.new(email_address: doug.email_address)
99
+ assert_equal doug, op.user
100
+ assert_equal "email_address", op.field_configurations[:user][:find_by]
101
+ end
102
+
103
+ def test_it_allows_a_find_by_to_be_set
104
+ all_mock = mock
105
+ ::User.expects(:all).returns(all_mock)
106
+ all_mock.expects(:find_by!).with(email_address: doug.email_address).returns(doug)
107
+
108
+ op = ::AssociationWithFindByKeyOp.new(user_id: doug.email_address)
109
+ assert_equal doug, op.user
110
+ assert_equal "email_address", op.field_configurations[:user][:find_by]
111
+ end
112
+
113
+ def test_values_are_correct_for_find_by_usage
114
+ op = ::AssociationWithFindByKeyOp.new(user: doug)
115
+ assert_equal doug, op.user
116
+ assert_equal doug.email_address, op.user_id
117
+ end
118
+
119
+ def test_values_are_correct_for_foreign_key_usage
120
+ op = ::AssociationWithForeignKeyOp.new(user: doug)
121
+ assert_equal doug, op.user
122
+ assert_equal doug.id, op.user_identifier
123
+ end
124
+
125
+ def test_values_are_correct_for_both_foreign_key_and_find_by_usage
126
+ op = ::AssociationWithFindByAndForeignKeyOp.new(user: doug)
127
+ assert_equal doug, op.user
128
+ assert_equal doug.email_address, op.email_address
129
+ assert_equal false, op.respond_to?(:user_id)
130
+ end
131
+
73
132
  def test_it_inherits_associations_via_fields_from
74
133
  all_mock = mock
75
134
 
76
135
  ::User.expects(:all).returns(all_mock)
77
- all_mock.expects(:find).with(1).returns(doug)
136
+ all_mock.expects(:find_by!).with(id: 1).returns(doug)
78
137
 
79
138
  op = ::InheritedSimpleAssociation.new(user_type: "User", user_id: doug.id)
80
139
  assert_equal doug, op.user
@@ -88,7 +147,7 @@ module Subroutine
88
147
 
89
148
  ::User.expects(:all).returns(all_mock)
90
149
  all_mock.expects(:unscoped).returns(unscoped_mock)
91
- unscoped_mock.expects(:find).with(1).returns(doug)
150
+ unscoped_mock.expects(:find_by!).with(id: 1).returns(doug)
92
151
 
93
152
  op = ::InheritedUnscopedAssociation.new(user_type: "User", user_id: doug.id)
94
153
  assert_equal doug, op.user
@@ -100,7 +159,7 @@ module Subroutine
100
159
  all_mock = mock
101
160
  ::User.expects(:all).never
102
161
  ::AdminUser.expects(:all).returns(all_mock)
103
- all_mock.expects(:find).with(1).returns(doug)
162
+ all_mock.expects(:find_by!).with(id: 1).returns(doug)
104
163
 
105
164
  op = ::InheritedPolymorphicAssociationOp.new(admin_type: "AdminUser", admin_id: doug.id)
106
165
  assert_equal doug, op.admin
@@ -118,5 +118,17 @@ module Subroutine
118
118
  RequireUserOp.new(user.id)
119
119
  end
120
120
 
121
+ def test_actioncontroller_parameters_can_be_provided
122
+ raw_params = { some_input: "foobarbaz" }.with_indifferent_access
123
+ params = ::ActionController::Parameters.new(raw_params)
124
+ op = RequireUserOp.new(user, params)
125
+ op.submit!
126
+
127
+ assert_equal "foobarbaz", op.some_input
128
+
129
+ assert_equal raw_params, op.params
130
+ assert_equal user, op.current_user
131
+ end
132
+
121
133
  end
122
134
  end
@@ -42,6 +42,13 @@ module Subroutine
42
42
  assert_equal [:baz], op.field_configurations.keys.sort
43
43
  end
44
44
 
45
+ def test_fields_from_ignores_except_association_fields
46
+ op = ::ExceptAssociationOp.new
47
+ refute op.field_configurations.key?(:admin)
48
+ refute op.field_configurations.key?(:admin_id)
49
+ refute op.field_configurations.key?(:admin_type)
50
+ end
51
+
45
52
  def test_fields_from_only_fields
46
53
  op = ::OnlyFooBarOp.new
47
54
  assert op.field_configurations.key?(:foo)
@@ -49,6 +56,13 @@ module Subroutine
49
56
  refute_equal [:baz], op.field_configurations.keys.sort
50
57
  end
51
58
 
59
+ def test_fields_from_only_association_fields
60
+ op = ::OnlyAssociationOp.new
61
+ assert op.field_configurations.key?(:admin)
62
+ assert op.field_configurations.key?(:admin_type)
63
+ assert op.field_configurations.key?(:admin_id)
64
+ end
65
+
52
66
  def test_defaults_declaration_options
53
67
  op = ::DefaultsOp.new
54
68
  assert_equal "foo", op.foo
@@ -113,6 +127,13 @@ module Subroutine
113
127
  assert_equal ["has gotta be @admin.com"], op.errors[:email]
114
128
  end
115
129
 
130
+ def test_validation_errors_can_be_inherited_and_prefixed
131
+ op = PrefixedInputsOp.new(user_email_address: "foo@bar.com")
132
+ refute op.submit
133
+
134
+ assert_equal ["has gotta be @admin.com"], op.errors[:user_email_address]
135
+ end
136
+
116
137
  def test_when_valid_perform_completes_it_returns_control
117
138
  op = ::SignupOp.new(email: "foo@bar.com", password: "password123")
118
139
  op.submit!
@@ -125,6 +146,11 @@ module Subroutine
125
146
  assert_equal "foo@bar.com", u.email_address
126
147
  end
127
148
 
149
+ def test_instance_and_class_submit_bang_return_instance
150
+ assert ::SignupOp.new(email: "foo@bar.com", password: "password123").submit!.is_a?(::SignupOp)
151
+ assert ::SignupOp.submit!(email: "foo@bar.com", password: "password123").is_a?(::SignupOp)
152
+ end
153
+
128
154
  def test_it_raises_an_error_when_used_with_a_bang_and_performing_or_validation_fails
129
155
  op = ::SignupOp.new(email: "foo@bar.com")
130
156
 
@@ -304,5 +330,17 @@ module Subroutine
304
330
  end
305
331
  end
306
332
 
333
+ def test_actioncontroller_parameters_can_be_provided
334
+ raw_params = { email: "foo@bar.com", password: "password123!" }.with_indifferent_access
335
+ params = ::ActionController::Parameters.new(raw_params)
336
+ op = SignupOp.new(params)
337
+ op.submit!
338
+
339
+ assert_equal "foo@bar.com", op.email
340
+ assert_equal "password123!", op.password
341
+
342
+ assert_equal raw_params, op.params
343
+ end
344
+
307
345
  end
308
346
  end
data/test/support/ops.rb CHANGED
@@ -23,6 +23,14 @@ class User
23
23
  new(id: id)
24
24
  end
25
25
 
26
+ def self.find_by(params)
27
+ new(params)
28
+ end
29
+
30
+ def self.find_by!(params)
31
+ find_by(params) || raise
32
+ end
33
+
26
34
  end
27
35
 
28
36
  class AdminUser < ::User
@@ -177,6 +185,8 @@ class RequireUserOp < OpWithAuth
177
185
 
178
186
  require_user!
179
187
 
188
+ string :some_input
189
+
180
190
  end
181
191
 
182
192
  class RequireNoUserOp < OpWithAuth
@@ -206,7 +216,7 @@ class CustomAuthorizeOp < OpWithAuth
206
216
  protected
207
217
 
208
218
  def authorize_user_is_correct
209
- unauthorized! unless current_user.email_address.to_s =~ /example\.com$/
219
+ unauthorized! unless current_user.email_address.to_s =~ /example\.com$/ # rubocop:disable Performance/RegexpMatch
210
220
  end
211
221
 
212
222
  end
@@ -302,6 +312,36 @@ class AssociationWithClassOp < ::OpWithAssociation
302
312
 
303
313
  end
304
314
 
315
+ class AssociationWithForeignKeyOp < ::OpWithAssociation
316
+
317
+ association :user, foreign_key: "user_identifier"
318
+
319
+ end
320
+
321
+ class AssociationWithFindByKeyOp < ::OpWithAssociation
322
+
323
+ association :user, find_by: "email_address"
324
+
325
+ end
326
+
327
+ class AssociationWithFindByAndForeignKeyOp < ::OpWithAssociation
328
+
329
+ association :user, foreign_key: "email_address", find_by: "email_address"
330
+
331
+ end
332
+
333
+ class ExceptAssociationOp < ::Subroutine::Op
334
+
335
+ fields_from ::PolymorphicAssociationOp, except: %i[admin]
336
+
337
+ end
338
+
339
+ class OnlyAssociationOp < ::Subroutine::Op
340
+
341
+ fields_from ::PolymorphicAssociationOp, only: %i[admin]
342
+
343
+ end
344
+
305
345
  class InheritedSimpleAssociation < ::Subroutine::Op
306
346
 
307
347
  fields_from SimpleAssociationOp
@@ -433,3 +473,15 @@ class CustomFailureClassOp < ::Subroutine::Op
433
473
  end
434
474
 
435
475
  end
476
+
477
+ class PrefixedInputsOp < ::Subroutine::Op
478
+
479
+ string :user_email_address
480
+
481
+ def perform
482
+ u = AdminUser.new(email_address: user_email_address)
483
+ u.valid?
484
+ inherit_errors(u, prefix: :user_)
485
+ end
486
+
487
+ end
data/test/test_helper.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'subroutine'
4
- require 'minitest/autorun'
5
- require 'minitest/unit'
3
+ require "subroutine"
4
+ require "minitest/autorun"
5
+ require "minitest/unit"
6
6
 
7
- require 'minitest/reporters'
8
- require 'mocha/minitest'
7
+ require "minitest/reporters"
8
+ require "mocha/minitest"
9
9
 
10
10
  require "byebug"
11
+ require "action_controller"
11
12
 
12
13
  Minitest::Reporters.use!([Minitest::Reporters::DefaultReporter.new])
13
14
 
14
15
  class TestCase < ::Minitest::Test; end
15
16
 
16
- require_relative 'support/ops'
17
+ require_relative "support/ops"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subroutine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.rc2
4
+ version: 2.0.0.beta
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Nelson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-30 00:00:00.000000000 Z
11
+ date: 2021-06-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 4.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 4.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 4.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: actionpack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '4.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '4.0'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: bundler
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +67,7 @@ dependencies:
39
67
  - !ruby/object:Gem::Version
40
68
  version: '0'
41
69
  - !ruby/object:Gem::Dependency
42
- name: rake
70
+ name: byebug
43
71
  requirement: !ruby/object:Gem::Requirement
44
72
  requirements:
45
73
  - - ">="
@@ -109,7 +137,7 @@ dependencies:
109
137
  - !ruby/object:Gem::Version
110
138
  version: '0'
111
139
  - !ruby/object:Gem::Dependency
112
- name: byebug
140
+ name: rake
113
141
  requirement: !ruby/object:Gem::Requirement
114
142
  requirements:
115
143
  - - ">="
@@ -133,6 +161,7 @@ files:
133
161
  - ".ruby-gemset"
134
162
  - ".ruby-version"
135
163
  - ".travis.yml"
164
+ - CHANGELOG.MD
136
165
  - Gemfile
137
166
  - LICENSE.txt
138
167
  - README.md