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 +4 -4
- data/CHANGELOG.MD +111 -0
- data/Gemfile +1 -1
- data/README.md +22 -9
- data/gemfiles/am41.gemfile +3 -1
- data/gemfiles/am42.gemfile +2 -0
- data/gemfiles/am50.gemfile +2 -1
- data/gemfiles/am51.gemfile +2 -1
- data/gemfiles/am52.gemfile +2 -1
- data/gemfiles/am60.gemfile +2 -1
- data/lib/subroutine/association_fields.rb +25 -19
- data/lib/subroutine/association_fields/configuration.rb +22 -10
- data/lib/subroutine/auth.rb +12 -5
- data/lib/subroutine/fields.rb +24 -4
- data/lib/subroutine/fields/configuration.rb +7 -2
- data/lib/subroutine/op.rb +16 -27
- data/lib/subroutine/version.rb +2 -2
- data/subroutine.gemspec +10 -7
- data/test/subroutine/association_test.rb +66 -7
- data/test/subroutine/auth_test.rb +12 -0
- data/test/subroutine/base_test.rb +38 -0
- data/test/support/ops.rb +53 -1
- data/test/test_helper.rb +7 -6
- metadata +33 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d0ade77ec5abb9601e1dce7bc53e34baac35c978a41aef3268b57ee5b9def6e5
|
4
|
+
data.tar.gz: c450947886efc34b26b202b52202909f44735a2d5f26a3c1af51fdf93be509c6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
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 :
|
23
|
+
outputs :user
|
24
|
+
outputs :business
|
21
25
|
|
22
26
|
protected
|
23
27
|
|
24
28
|
def perform
|
25
29
|
u = create_user!
|
26
|
-
|
30
|
+
b = create_business!(u)
|
31
|
+
|
32
|
+
deliver_welcome_email(u)
|
27
33
|
|
28
|
-
output :
|
34
|
+
output :user, u
|
35
|
+
output :business, b
|
29
36
|
end
|
30
37
|
|
31
38
|
def create_user!
|
32
|
-
User.create!(
|
39
|
+
User.create!(name: name, email: email, password: password)
|
33
40
|
end
|
34
41
|
|
35
|
-
def
|
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
|
-
|
49
|
-
|
50
|
-
[
|
51
|
-
[
|
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)
|
data/gemfiles/am41.gemfile
CHANGED
data/gemfiles/am42.gemfile
CHANGED
data/gemfiles/am50.gemfile
CHANGED
data/gemfiles/am51.gemfile
CHANGED
data/gemfiles/am52.gemfile
CHANGED
data/gemfiles/am60.gemfile
CHANGED
@@ -69,16 +69,17 @@ module Subroutine
|
|
69
69
|
config = ::Subroutine::AssociationFields::Configuration.new(field_name, options)
|
70
70
|
|
71
71
|
if config.polymorphic?
|
72
|
-
|
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.
|
77
|
+
#{config.inferred_foreign_type.inspect}
|
77
78
|
end
|
78
79
|
EV
|
79
80
|
end
|
80
81
|
|
81
|
-
|
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
|
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,
|
108
|
-
|
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&.
|
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
|
-
|
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(
|
177
|
-
|
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
|
191
|
+
scope = scope.unscoped if config.unscoped?
|
186
192
|
|
187
|
-
scope.
|
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.
|
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
|
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
|
35
|
-
|
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
|
data/lib/subroutine/auth.rb
CHANGED
@@ -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
|
-
|
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!
|
data/lib/subroutine/fields.rb
CHANGED
@@ -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
|
51
|
-
next if
|
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?
|
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?
|
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
|
-
|
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
|
-
|
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 |
|
136
|
-
if
|
137
|
-
|
138
|
-
|
139
|
-
|
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(
|
130
|
+
errors.add(:base, error_object.full_message(field_name, error))
|
142
131
|
end
|
143
132
|
end
|
144
133
|
|
data/lib/subroutine/version.rb
CHANGED
data/subroutine.gemspec
CHANGED
@@ -1,15 +1,16 @@
|
|
1
|
-
#
|
2
|
-
|
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
|
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 =
|
12
|
-
spec.description =
|
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 "
|
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 "
|
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(:
|
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(:
|
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(:
|
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(:
|
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(:
|
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(:
|
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(:
|
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
|
4
|
-
require
|
5
|
-
require
|
3
|
+
require "subroutine"
|
4
|
+
require "minitest/autorun"
|
5
|
+
require "minitest/unit"
|
6
6
|
|
7
|
-
require
|
8
|
-
require
|
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
|
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:
|
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:
|
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:
|
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:
|
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
|