gl_command 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/README.md +202 -0
- data/gl_command.gemspec +22 -0
- data/lib/gl_command/callable.rb +218 -0
- data/lib/gl_command/chainable.rb +90 -0
- data/lib/gl_command/chainable_context.rb +46 -0
- data/lib/gl_command/context.rb +163 -0
- data/lib/gl_command/context_inspect.rb +44 -0
- data/lib/gl_command/validatable.rb +33 -0
- data/lib/gl_command/version.rb +3 -0
- data/lib/gl_command.rb +17 -0
- metadata +83 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 11a5d5ab9dd4a28700cb671153c1a757612f5b735194a28ad22f8be660a09250
|
4
|
+
data.tar.gz: a3a351134af335563ee6e970d5920d08e52f2b4375cef1c8adc87e555bd1e64f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4344a62dca3bccad299425f9d22db05ca7e3062518c0f4a0c3b1be2d3708de8ccb3d310380c5a4eb73b515a21c4866c8b3385f9ee18c23e9389fd49e9afb8803
|
7
|
+
data.tar.gz: '08c842fe87ac92bc793fad76498e0b12ecc772a8d5385fea2d5c34670115ba40a17545227e572a5ce1bcee9b3bc6c52f2e70f59e45a8755a871b93ebe3d344a2'
|
data/LICENSE
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
Apache License
|
2
|
+
Version 2.0, January 2004
|
3
|
+
http://www.apache.org/licenses/
|
4
|
+
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
6
|
+
|
7
|
+
1. Definitions.
|
8
|
+
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
11
|
+
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
13
|
+
the copyright owner that is granting the License.
|
14
|
+
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
16
|
+
other entities that control, are controlled by, or are under common
|
17
|
+
control with that entity. For the purposes of this definition,
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
19
|
+
direction or management of such entity, whether by contract or
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
22
|
+
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
24
|
+
exercising permissions granted by this License.
|
25
|
+
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
27
|
+
including but not limited to software source code, documentation
|
28
|
+
source, and configuration files.
|
29
|
+
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
31
|
+
transformation or translation of a Source form, including but
|
32
|
+
not limited to compiled object code, generated documentation,
|
33
|
+
and conversions to other media types.
|
34
|
+
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
36
|
+
Object form, made available under the License, as indicated by a
|
37
|
+
copyright notice that is included in or attached to the work
|
38
|
+
(an example is provided in the Appendix below).
|
39
|
+
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
46
|
+
the Work and Derivative Works thereof.
|
47
|
+
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
49
|
+
the original version of the Work and any modifications or additions
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
61
|
+
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
64
|
+
subsequently incorporated within the Work.
|
65
|
+
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
72
|
+
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
78
|
+
where such license applies only to those patent claims licensable
|
79
|
+
by such Contributor that are necessarily infringed by their
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
82
|
+
institute patent litigation against any entity (including a
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
85
|
+
or contributory patent infringement, then any patent licenses
|
86
|
+
granted to You under this License for that Work shall terminate
|
87
|
+
as of the date such litigation is filed.
|
88
|
+
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
91
|
+
modifications, and in Source or Object form, provided that You
|
92
|
+
meet the following conditions:
|
93
|
+
|
94
|
+
(a) You must give any other recipients of the Work or
|
95
|
+
Derivative Works a copy of this License; and
|
96
|
+
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
98
|
+
stating that You changed the files; and
|
99
|
+
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
102
|
+
attribution notices from the Source form of the Work,
|
103
|
+
excluding those notices that do not pertain to any part of
|
104
|
+
the Derivative Works; and
|
105
|
+
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
108
|
+
include a readable copy of the attribution notices contained
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
111
|
+
of the following places: within a NOTICE text file distributed
|
112
|
+
as part of the Derivative Works; within the Source form or
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
114
|
+
within a display generated by the Derivative Works, if and
|
115
|
+
wherever such third-party notices normally appear. The contents
|
116
|
+
of the NOTICE file are for informational purposes only and
|
117
|
+
do not modify the License. You may add Your own attribution
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
120
|
+
that such additional attribution notices cannot be construed
|
121
|
+
as modifying the License.
|
122
|
+
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
124
|
+
may provide additional or different license terms and conditions
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
128
|
+
the conditions stated in this License.
|
129
|
+
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
133
|
+
this License, without any additional terms or conditions.
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
135
|
+
the terms of any separate license agreement you may have executed
|
136
|
+
with Licensor regarding such Contributions.
|
137
|
+
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
140
|
+
except as required for reasonable and customary use in describing the
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
142
|
+
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
152
|
+
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
158
|
+
incidental, or consequential damages of any character arising as a
|
159
|
+
result of this License or out of the use or inability to use the
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
162
|
+
other commercial damages or losses), even if such Contributor
|
163
|
+
has been advised of the possibility of such damages.
|
164
|
+
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
168
|
+
or other liability obligations and/or rights consistent with this
|
169
|
+
License. However, in accepting such obligations, You may act only
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
174
|
+
of your accepting any such warranty or additional liability.
|
175
|
+
|
176
|
+
END OF TERMS AND CONDITIONS
|
177
|
+
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
179
|
+
|
180
|
+
To apply the Apache License to your work, attach the following
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
182
|
+
replaced with your own identifying information. (Don't include
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
184
|
+
comment syntax for the file format. We also recommend that a
|
185
|
+
file or class name and description of purpose be included on the
|
186
|
+
same "printed page" as the copyright notice for easier
|
187
|
+
identification within third-party archives.
|
188
|
+
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
190
|
+
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
192
|
+
you may not use this file except in compliance with the License.
|
193
|
+
You may obtain a copy of the License at
|
194
|
+
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
196
|
+
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
200
|
+
See the License for the specific language governing permissions and
|
201
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,202 @@
|
|
1
|
+
# GLCommand
|
2
|
+
|
3
|
+
`GLCommand` is a way to encapsulate business logic.
|
4
|
+
|
5
|
+
Calling a command returns a `GLCommand::Context` which has these properties:
|
6
|
+
|
7
|
+
- The arguments that were passed in to the command `.call` method (set via `allows` `requires`)
|
8
|
+
- The returns from the `.call` method
|
9
|
+
- `error` (which contains the error, if an error was raised)
|
10
|
+
- `full_error_message` - which renders a string from the error, or can be set explicitly (used to show a legible error to the user).
|
11
|
+
- `success` - `true` if the command executed without an error (false if there is an `error`)
|
12
|
+
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add the following line to your Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'gl_command'
|
20
|
+
```
|
21
|
+
|
22
|
+
Download and install the gem:
|
23
|
+
```sh
|
24
|
+
bundle install
|
25
|
+
```
|
26
|
+
|
27
|
+
## Using GLCommand
|
28
|
+
|
29
|
+
Invoke a command with `.call` or `.call!`
|
30
|
+
|
31
|
+
`.call` will return the `GLCommand::Context`, with `error` assigned (if there is an error)
|
32
|
+
|
33
|
+
`.call!` will raise the error (if there is an error), otherwise it will return the `GLCommand::Context`
|
34
|
+
|
35
|
+
General rules for deciding whether to use `.call!`
|
36
|
+
|
37
|
+
- In controllers use `.call` (make sure you check that it succeeds and [render errors](#displaying-errors) appropriately)
|
38
|
+
- In background jobs and rake tasks, use `.call!`
|
39
|
+
- Use `.call!` when calling a command within another command. If the inner command fails, it will assign errors to the outer command.
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
class SomeCommand < GLCommand::Callable
|
43
|
+
returns :data
|
44
|
+
|
45
|
+
def call
|
46
|
+
# If OtherCommand fails, SomeCommand will also fail - with the error from OtherCommand
|
47
|
+
result = OtherCommand.call!
|
48
|
+
context.data = result.data
|
49
|
+
end
|
50
|
+
end
|
51
|
+
```
|
52
|
+
|
53
|
+
## Success/Failure
|
54
|
+
|
55
|
+
GLCommand context's are successful by default (`successful?` aliases `success?`).
|
56
|
+
|
57
|
+
They are a failure (`success? == false`) if the context has an error.
|
58
|
+
|
59
|
+
Here are the ways of adding an error to a command:
|
60
|
+
|
61
|
+
- Raising an exception
|
62
|
+
- Immediately stops execution
|
63
|
+
- Calling `stop_and_fail!`
|
64
|
+
- Immediately stops execution
|
65
|
+
- Failing a validation
|
66
|
+
- Validation errors are checked before the `call` method is invoked (if `valid? == false` the command will return).
|
67
|
+
- If validations are added during the `call` method, the command fails after call
|
68
|
+
- Directly assigning `context.error` or `context.full_error_message` to a non-nil value
|
69
|
+
- Checked after `call` method finishes
|
70
|
+
|
71
|
+
If you invoke a command with `.call!` all of the above will raise an exception
|
72
|
+
|
73
|
+
If a command fails, it will call its `rollback` method before returning (even when invoked with `.call!`)
|
74
|
+
|
75
|
+
### Displaying errors
|
76
|
+
|
77
|
+
In addition to encapsulating business logic, GLCommand also standardizes error handling.
|
78
|
+
|
79
|
+
This means that rather than having to rescue errors in controllers, you can just render the command's `full_error_message`
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
result = GLCommand::Callable.call(params)
|
83
|
+
if result.success?
|
84
|
+
redirect_to new_controller_action
|
85
|
+
else
|
86
|
+
flash[:error] = result.full_error_message
|
87
|
+
redirect_back
|
88
|
+
end
|
89
|
+
```
|
90
|
+
|
91
|
+
In general, use `context.full_error_message` to render errors.
|
92
|
+
|
93
|
+
|
94
|
+
### `stop_and_fail!`
|
95
|
+
|
96
|
+
Use `stop_and_fail!` to immediately stop a command and raise an error (`GLCommand::StopAndFail` by default)
|
97
|
+
|
98
|
+
The argument to `stop_and_fail!` is assign to the `context.error`
|
99
|
+
|
100
|
+
- If you pass an exception, that exception will be raised and/or sent to Sentry
|
101
|
+
- Otherwise, the error will be a `GLCommand::StopAndFail` and what was passed will be assigned to `full_error_message`
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
# Passing a string:
|
105
|
+
stop_and_fail!('An error message')
|
106
|
+
context.error # => GLCommand::StopAndFail
|
107
|
+
context.full_error_message # => 'An error message'
|
108
|
+
|
109
|
+
# Passing an exception:
|
110
|
+
stop_and_fail!(ActiveRecord::RecordNotFound)
|
111
|
+
context.error # => ActiveRecord::RecordNotFound
|
112
|
+
context.full_error_message # => ActiveRecord::RecordNotFound
|
113
|
+
|
114
|
+
# Passing an exception with an error message
|
115
|
+
stop_and_fail!(ActiveRecord::RecordNotFound.new('Some error message'))
|
116
|
+
context.error # => ActiveRecord::RecordNotFound
|
117
|
+
context.full_error_message # => 'Some error message'
|
118
|
+
```
|
119
|
+
|
120
|
+
You can also include `no_notify: true`, which prevents `GLExceptionNotifier` from being called.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
# Sentry is notified when #call fails by default:
|
124
|
+
stop_and_fail!('An error message') # GLExceptionNotifier is called
|
125
|
+
|
126
|
+
# If you don't want to alert Sentry when the command fails in a specific way:
|
127
|
+
stop_and_fail!('An error message', no_notify: true) # GLExceptionNotifier is *not* called
|
128
|
+
```
|
129
|
+
|
130
|
+
|
131
|
+
### Validations
|
132
|
+
|
133
|
+
You can add validations to `GLCommand::Callable` and `GLCommand::Chainable`.
|
134
|
+
|
135
|
+
If the validations fail, the command returns `success: false` without executing.
|
136
|
+
|
137
|
+
If validations fail, `GLExceptionNotifier` is not called
|
138
|
+
|
139
|
+
|
140
|
+
## GLExceptionNotifier
|
141
|
+
|
142
|
+
[ExceptionNotifier](https://github.com/givelively/gl_exception_notifier) is Give Lively's wrapper for notify our error monitoring service (currently [Sentry](https://github.com/getsentry/sentry-ruby))
|
143
|
+
|
144
|
+
When a command fails `GLExceptionNotifier` is called, unless:
|
145
|
+
|
146
|
+
- The command is invoked with `call!` (because an error will be raised, which will alert Sentry)
|
147
|
+
- The failure is a validation failure
|
148
|
+
- `stop_and_fail!` is called with `no_notify: true` - for example `stop_and_fail!('An error message', no_notify: true)`
|
149
|
+
|
150
|
+
**NOTE:** commands that invoke other commands with `call!` inherit the no_notify property of called command.
|
151
|
+
|
152
|
+
```ruby
|
153
|
+
class InteriorCommand < GLCommand::Callable
|
154
|
+
def call
|
155
|
+
stop_and_fail!('An error message', no_notify: true)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
class MainCommand < GLCommand::Callable
|
160
|
+
def call
|
161
|
+
# Use call! in commands that invoke other commands to have the errors automatically bubble up
|
162
|
+
InteriorCommand.call!
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# This won't call GLExceptionNotifier, because no_notify: true was used on InteriorCommand
|
167
|
+
result = MainCommand.call
|
168
|
+
result.success? # => false
|
169
|
+
result.full_error_message # => 'An error message'
|
170
|
+
```
|
171
|
+
|
172
|
+
## Chainable
|
173
|
+
|
174
|
+
Bundle commands together with `GLCommand::Chainable`
|
175
|
+
|
176
|
+
- Automatically passes the requires/allows and returns between the commands
|
177
|
+
- Returns a `GLCommand::ChainableContext`, that inherits from `GLCommand::Context`. It adds a `Commands` array that contains the Command class names that were called.
|
178
|
+
- A command in the chain failing will call `rollback` on itself and then each command in the context `Command` array (in reverse order)
|
179
|
+
|
180
|
+
|
181
|
+
If you need to do logic in the `GLCommand::Chainable` class, define the `call` method and invoke `chain` from that.
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class SomeChain < GLCommand::Chainable
|
185
|
+
requires :item
|
186
|
+
|
187
|
+
returns :new_item
|
188
|
+
|
189
|
+
chain CommmandOne, CommandTwo
|
190
|
+
|
191
|
+
def call
|
192
|
+
# Add some logic goes here
|
193
|
+
chain(item:) # Automatically assigns the return to the context
|
194
|
+
# Additional logic here
|
195
|
+
end
|
196
|
+
end
|
197
|
+
```
|
198
|
+
|
199
|
+
|
200
|
+
---
|
201
|
+
|
202
|
+
This library is influenced by [interactors](https://github.com/collectiveidea/interactor) and inspired by the [Command Pattern](https://en.wikipedia.org/wiki/Command_pattern).
|
data/gl_command.gemspec
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/gl_command/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'gl_command'
|
7
|
+
spec.version = GLCommand::VERSION
|
8
|
+
spec.authors = ['Give Lively']
|
9
|
+
spec.summary = 'Give Lively Commands'
|
10
|
+
spec.homepage = 'https://github.com/givelively/gl_command'
|
11
|
+
spec.license = 'Apache'
|
12
|
+
spec.platform = Gem::Platform::RUBY
|
13
|
+
|
14
|
+
spec.required_ruby_version = '>= 3.1'
|
15
|
+
spec.extra_rdoc_files = ['README.md']
|
16
|
+
spec.files = %w[gl_command.gemspec README.md LICENSE] + `git ls-files | grep -E '^(lib)'`.split("\n")
|
17
|
+
|
18
|
+
spec.add_dependency 'activerecord', '>= 3.2.0'
|
19
|
+
spec.add_dependency 'gl_exception_notifier', '>= 1.0.2'
|
20
|
+
|
21
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
22
|
+
end
|
@@ -0,0 +1,218 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/module'
|
4
|
+
require 'gl_exception_notifier'
|
5
|
+
require 'gl_command/validatable'
|
6
|
+
|
7
|
+
module GLCommand
|
8
|
+
class Callable
|
9
|
+
DEFAULT_OPTS = { raise_errors: false, skip_unknown_parameters: true }.freeze
|
10
|
+
RESERVED_WORDS = (DEFAULT_OPTS.keys + GLCommand::ChainableContext.reserved_words).sort.freeze
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Make raise_errors and skip_unknown_parameters reserved and raise if they're passed in
|
14
|
+
def call(*posargs, **args)
|
15
|
+
if arguments_and_returns.intersect?(RESERVED_WORDS)
|
16
|
+
raise ArgumentError,
|
17
|
+
"You used reserved word(s): #{arguments_and_returns & RESERVED_WORDS}\n" \
|
18
|
+
'(check GLCommand::Callable::RESERVED_WORDS for the full list)'
|
19
|
+
end
|
20
|
+
|
21
|
+
if posargs.any?
|
22
|
+
raise ArgumentError,
|
23
|
+
"`call` only supports keyword args, not positional - you passed: '#{posargs}'"
|
24
|
+
end
|
25
|
+
|
26
|
+
# DEFAULT_OPTS contains skip_unknown_parameters: true - so it raises on call
|
27
|
+
# (rather than in context initialize) to make errors more legible
|
28
|
+
opts = DEFAULT_OPTS.merge(raise_errors: args.delete(:raise_errors)).compact
|
29
|
+
# args are passed in in perform_call(args) so that invalid args raise in a legible place
|
30
|
+
new(build_context(**args.merge(opts))).perform_call(args)
|
31
|
+
end
|
32
|
+
|
33
|
+
def call!(*posargs, **args)
|
34
|
+
call(*posargs, **args.merge(raise_errors: true))
|
35
|
+
end
|
36
|
+
|
37
|
+
def build_context(raise_errors: false, skip_unknown_parameters: false,
|
38
|
+
**arguments_and_returns)
|
39
|
+
context_class.new(self, raise_errors:, skip_unknown_parameters:, **arguments_and_returns)
|
40
|
+
end
|
41
|
+
|
42
|
+
def requires(*attributes, **strong_attributes)
|
43
|
+
@requires ||= strong_args_hash(*attributes, **strong_attributes).freeze
|
44
|
+
end
|
45
|
+
|
46
|
+
def allows(*attributes, **strong_attributes)
|
47
|
+
@allows ||= strong_args_hash(*attributes, **strong_attributes).freeze
|
48
|
+
end
|
49
|
+
|
50
|
+
def returns(*attributes, **strong_attributes)
|
51
|
+
# NOTE: Because returns aren't validated, we don't store the types (only store keys)
|
52
|
+
@returns ||= strong_args_hash(*attributes, **strong_attributes).keys.freeze
|
53
|
+
end
|
54
|
+
|
55
|
+
# arguments are what's passed to the .call command (the allows and requires)
|
56
|
+
def arguments
|
57
|
+
return @arguments if defined?(@arguments)
|
58
|
+
|
59
|
+
duplicated_keys = requires.keys & allows.keys
|
60
|
+
raise "Duplicated: #{duplicated_keys} - in both requires and allows" if duplicated_keys.any?
|
61
|
+
|
62
|
+
@arguments = (requires.keys + allows.keys).freeze
|
63
|
+
|
64
|
+
delegate(*@arguments + returns, to: :context)
|
65
|
+
@arguments
|
66
|
+
end
|
67
|
+
|
68
|
+
# arguments_and_returns is just the keys (names) of the arguments and returns
|
69
|
+
def arguments_and_returns
|
70
|
+
(arguments + returns).uniq
|
71
|
+
end
|
72
|
+
|
73
|
+
# Used internally by GLCommand (probably don't reference them in your own GLCommands)
|
74
|
+
# is true in GLCommand::Chainable
|
75
|
+
def chain?
|
76
|
+
false
|
77
|
+
end
|
78
|
+
|
79
|
+
def rescue_from(error_class, with:)
|
80
|
+
error_handlers[error_class] = with
|
81
|
+
end
|
82
|
+
|
83
|
+
def error_handlers
|
84
|
+
@error_handlers ||= {}
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def context_class
|
90
|
+
chain? ? GLCommand::ChainableContext : GLCommand::Context
|
91
|
+
end
|
92
|
+
|
93
|
+
def strong_args_hash(*attributes, **strong_attributes)
|
94
|
+
# Convert attributes to strong attributes with nil value
|
95
|
+
attributes.index_with { nil }.merge(strong_attributes)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
include GLCommand::Validatable
|
100
|
+
|
101
|
+
attr_reader :context
|
102
|
+
|
103
|
+
def initialize(context = nil)
|
104
|
+
@context = context
|
105
|
+
end
|
106
|
+
|
107
|
+
def perform_call(args)
|
108
|
+
raise_for_invalid_args!(**args)
|
109
|
+
call_with_callbacks
|
110
|
+
raise_unless_chained_or_skipped if self.class.chain? # defined in GLCommand::Chainable
|
111
|
+
context.failure? ? handle_failure : context
|
112
|
+
rescue StandardError => e
|
113
|
+
handle_failure(e)
|
114
|
+
end
|
115
|
+
|
116
|
+
def stop_and_fail!(passed_error = nil, no_notify: false)
|
117
|
+
# manually setting instance_variable because @no_notify shouldn't be updated in commands
|
118
|
+
# (e.g. context shouldn't have an attr_writer)
|
119
|
+
context.instance_variable_set(:@no_notify, no_notify)
|
120
|
+
context.error = passed_error
|
121
|
+
|
122
|
+
raise context.no_notifiable_error_to_raise # See comment in #handle_failure
|
123
|
+
end
|
124
|
+
|
125
|
+
# define a rollback method if you want to have actions for rolling back
|
126
|
+
# it is called in handle_failure
|
127
|
+
def rollback; end
|
128
|
+
|
129
|
+
# Ensure that call is overridden in subclass
|
130
|
+
def call
|
131
|
+
raise 'You must define the `call` instance method on your GLCommand'
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
# rubocop:disable Metrics/AbcSize
|
137
|
+
def handle_failure(e = nil)
|
138
|
+
context.error ||= e
|
139
|
+
|
140
|
+
call_rollbacks
|
141
|
+
|
142
|
+
# Don't call GLExceptionNotifier if:
|
143
|
+
# - already notified
|
144
|
+
# - raise_errors: true (because raising error will call sentry)
|
145
|
+
# - context.no_notify?
|
146
|
+
unless @notified || context.raise_errors? || context.no_notify?
|
147
|
+
GLExceptionNotifier.call(context.error)
|
148
|
+
@notified = true
|
149
|
+
end
|
150
|
+
|
151
|
+
return context unless context.raise_errors?
|
152
|
+
|
153
|
+
# NOTE: this is tricksy. Exception#cause stores the previous error that was raised.
|
154
|
+
# If we are in no_notify, raise CommandNoNotifyError first
|
155
|
+
# Then raise the original error (so that the error is just the original error, with a #cause)
|
156
|
+
# context.no_notify? checks error #cause - if it's CommandNoNotifyError, the context is no_notify
|
157
|
+
# This is so validation errors in chainables don't call GLExceptionNotifier
|
158
|
+
raise context.no_notifiable_error_to_raise
|
159
|
+
rescue GLCommand::CommandNoNotifyError
|
160
|
+
raise context.error # makes CommandNoNotifyError the cause
|
161
|
+
end
|
162
|
+
# rubocop:enable Metrics/AbcSize
|
163
|
+
|
164
|
+
# rubocop:disable Metrics/AbcSize
|
165
|
+
def call_with_callbacks
|
166
|
+
GLExceptionNotifier.breadcrumbs(data: { context: context.inspect }, message: self.class.to_s)
|
167
|
+
validate_validatable! # defined in GLCommand::Validatable
|
168
|
+
|
169
|
+
# This is the where the call actually happens
|
170
|
+
assign_returns(call)
|
171
|
+
rescue StandardError => e
|
172
|
+
handler = self.class.error_handlers[e.class]
|
173
|
+
handler ? send(handler, e) : raise(e)
|
174
|
+
end
|
175
|
+
|
176
|
+
def assign_returns(returned)
|
177
|
+
return if returned.is_a?(context.class) # prevent looping assignment
|
178
|
+
# Naive assign of the return. Only assigns if not already assigned, and if a single item
|
179
|
+
# Could be made to accept a hash instead and assign based on that...
|
180
|
+
return unless self.class.returns.count == 1 && context.returns[self.class.returns.first].nil?
|
181
|
+
|
182
|
+
context.assign_parameters(self.class.returns.first => returned)
|
183
|
+
end
|
184
|
+
# rubocop:enable Metrics/AbcSize
|
185
|
+
|
186
|
+
def call_rollbacks
|
187
|
+
return if defined?(@rolled_back) # Not sure this is required
|
188
|
+
|
189
|
+
@rolled_back = true
|
190
|
+
|
191
|
+
chain_rollback if self.class.chain? # defined in GLCommand::Chainable
|
192
|
+
rollback
|
193
|
+
end
|
194
|
+
|
195
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
196
|
+
def raise_for_invalid_args!(**args)
|
197
|
+
missing = (self.class.requires || {}).keys - args.keys
|
198
|
+
raise ArgumentError, "missing #{error_keys_str(missing)}" if missing.any?
|
199
|
+
|
200
|
+
unknown = args.keys - self.class.arguments - DEFAULT_OPTS.keys
|
201
|
+
raise ArgumentError, "unknown #{error_keys_str(unknown)}" if unknown.any?
|
202
|
+
|
203
|
+
# strong_attributes type checking
|
204
|
+
self.class.requires.merge(self.class.allows).each do |arg, type|
|
205
|
+
next if type.nil? || args[arg].is_a?(type)
|
206
|
+
# Validation skipped if allows and nil (but not if blank)
|
207
|
+
next if args[arg].nil? && self.class.allows.include?(arg)
|
208
|
+
|
209
|
+
raise GLCommand::ArgumentTypeError, ":#{arg} is not a #{type}"
|
210
|
+
end
|
211
|
+
end
|
212
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
213
|
+
|
214
|
+
def error_keys_str(keys)
|
215
|
+
"keyword#{keys.count > 1 ? 's' : ''}: #{keys.map { |k| ":#{k}" }.join(', ')}"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GLCommand
|
4
|
+
class Chainable < GLCommand::Callable
|
5
|
+
UNCHAINED_MESSAGE = '#chain method not called in GLCommand::Chainable #call. ' \
|
6
|
+
"The #call method *must* include 'chain(args)' or 'super' " \
|
7
|
+
'for chaining to take place!'
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def chain?
|
11
|
+
true
|
12
|
+
end
|
13
|
+
|
14
|
+
def chain(*commands)
|
15
|
+
@commands = commands.flatten
|
16
|
+
end
|
17
|
+
|
18
|
+
def commands
|
19
|
+
@commands || []
|
20
|
+
end
|
21
|
+
|
22
|
+
def chain_arguments_and_returns(*)
|
23
|
+
(chain_arguments + chain_returns).uniq.zip([]).to_h
|
24
|
+
end
|
25
|
+
|
26
|
+
def chain_arguments
|
27
|
+
@chain_arguments ||= commands.map(&:arguments).flatten.uniq
|
28
|
+
end
|
29
|
+
|
30
|
+
def chain_returns
|
31
|
+
@chain_returns ||= commands.map(&:returns).flatten.uniq
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
36
|
+
def chain(args)
|
37
|
+
return if @chain_skipped
|
38
|
+
|
39
|
+
@chain_called = true
|
40
|
+
context.assign_parameters(**args)
|
41
|
+
|
42
|
+
commands.map do |command|
|
43
|
+
cargs = context.chain_arguments_and_returns.slice(*command.arguments)
|
44
|
+
.merge(context.opts_hash)
|
45
|
+
|
46
|
+
result = command.call(**cargs)
|
47
|
+
context.assign_parameters(skip_unknown_parameters: true, **result.returns)
|
48
|
+
|
49
|
+
if result.success?
|
50
|
+
context.called << command
|
51
|
+
else
|
52
|
+
@notified = true # chained command already notified
|
53
|
+
errors.merge!(result.errors)
|
54
|
+
stop_and_fail!(result.error, no_notify: result.no_notify?)
|
55
|
+
break
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
60
|
+
|
61
|
+
def commands
|
62
|
+
self.class.commands
|
63
|
+
end
|
64
|
+
|
65
|
+
def chain_rollback
|
66
|
+
context.called.reverse_each do |command|
|
67
|
+
chain_params = context.chain_arguments_and_returns
|
68
|
+
.slice(*command.arguments_and_returns)
|
69
|
+
|
70
|
+
command.new(command.build_context(**chain_params)).rollback
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def call(**args)
|
75
|
+
chain(args)
|
76
|
+
end
|
77
|
+
|
78
|
+
def skip_chain
|
79
|
+
@chain_skipped = true
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def raise_unless_chained_or_skipped
|
85
|
+
return if @chain_called || @chain_skipped
|
86
|
+
|
87
|
+
raise UNCHAINED_MESSAGE
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GLCommand
|
4
|
+
class ChainableContext < GLCommand::Context
|
5
|
+
def self.reserved_words
|
6
|
+
%i[callable called failure no_notify raise_errors] + instance_methods
|
7
|
+
end
|
8
|
+
|
9
|
+
# Called at the end of GLCommand::Context initialize
|
10
|
+
def initialize_chain_context(**arguments_and_returns)
|
11
|
+
@called = []
|
12
|
+
@chain_arguments_and_returns = @klass.chain_arguments_and_returns(**arguments_and_returns)
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_accessor :called
|
16
|
+
|
17
|
+
def chain?
|
18
|
+
true
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_h
|
22
|
+
arguments.merge(returns)
|
23
|
+
end
|
24
|
+
|
25
|
+
def chain_arguments_and_returns
|
26
|
+
@chain_arguments_and_returns.merge(to_h)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def inspect_values
|
32
|
+
super + ["called=#{called}"]
|
33
|
+
end
|
34
|
+
|
35
|
+
def assignable_parameters
|
36
|
+
@assignable_parameters ||=
|
37
|
+
(@klass.arguments_and_returns + @chain_arguments_and_returns.keys).uniq.freeze
|
38
|
+
end
|
39
|
+
|
40
|
+
# Overrides Context method, called by assign_parameters
|
41
|
+
def assign_parameter_val(arg, val, skip_unknown_parameters:)
|
42
|
+
super
|
43
|
+
@chain_arguments_and_returns[arg] = val if @chain_arguments_and_returns.key?(arg)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'English'
|
4
|
+
# TODO: can we use forwardable instead of ActiveSupport delegate ?
|
5
|
+
require 'active_support/core_ext/module'
|
6
|
+
|
7
|
+
module GLCommand
|
8
|
+
class Context
|
9
|
+
def initialize(klass, raise_errors: false, skip_unknown_parameters: false,
|
10
|
+
**arguments_and_returns)
|
11
|
+
@klass = klass
|
12
|
+
@raise_errors = raise_errors.nil? ? false : raise_errors
|
13
|
+
@klass.arguments_and_returns.each { |key| singleton_class.class_eval { attr_accessor key } }
|
14
|
+
initialize_chain_context(**arguments_and_returns) if chain?
|
15
|
+
assign_parameters(skip_unknown_parameters:, **arguments_and_returns)
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO: figure out a different place to pass skip_unknown_parameters than initialize,
|
19
|
+
# it doesn't make sense to include in the opts hash
|
20
|
+
def opts_hash
|
21
|
+
{ raise_errors: raise_errors? }
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :klass, :error
|
25
|
+
attr_writer :full_error_message
|
26
|
+
|
27
|
+
delegate :errors, to: :@callable, allow_nil: true
|
28
|
+
|
29
|
+
def chain?
|
30
|
+
false
|
31
|
+
end
|
32
|
+
|
33
|
+
def returns
|
34
|
+
@klass.returns.index_with { |rattr| send(rattr) }
|
35
|
+
end
|
36
|
+
|
37
|
+
def arguments
|
38
|
+
@klass.arguments.index_with { |rattr| send(rattr) }
|
39
|
+
end
|
40
|
+
|
41
|
+
def raise_errors?
|
42
|
+
@raise_errors
|
43
|
+
end
|
44
|
+
|
45
|
+
def failure?
|
46
|
+
@failure || errors.present? || @full_error_message.present? || false
|
47
|
+
end
|
48
|
+
|
49
|
+
def success?
|
50
|
+
!failure?
|
51
|
+
end
|
52
|
+
|
53
|
+
# @no_notify is set by passing no_notify into stop_and_fail!
|
54
|
+
# If a command is no_notify within another command call!, inside_no_notify_error? is true
|
55
|
+
def no_notify?
|
56
|
+
@no_notify.presence || inside_no_notify_error?
|
57
|
+
end
|
58
|
+
|
59
|
+
def full_error_message
|
60
|
+
return nil if @full_error_message.blank? && error.blank?
|
61
|
+
|
62
|
+
ContextInspect.error(defined?(@full_error_message) ? @full_error_message : @error)
|
63
|
+
end
|
64
|
+
|
65
|
+
alias_method :successful?, :success?
|
66
|
+
|
67
|
+
def to_h
|
68
|
+
arguments.merge(returns)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Set up to make errors visible when specs fail
|
72
|
+
def inspect
|
73
|
+
"<#{self.class.name} #{inspect_values.join(', ')}, class=#{klass}>"
|
74
|
+
end
|
75
|
+
|
76
|
+
def assign_parameters(skip_unknown_parameters: false, **arguments_and_returns)
|
77
|
+
arguments_and_returns.each do |arg, val|
|
78
|
+
assign_parameter_val(arg, val, skip_unknown_parameters:)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# This is what makes validation work
|
83
|
+
def assign_callable(callable)
|
84
|
+
@callable = callable
|
85
|
+
end
|
86
|
+
|
87
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
88
|
+
def error=(passed_error = nil)
|
89
|
+
@failure = true
|
90
|
+
|
91
|
+
@error =
|
92
|
+
if exception?(passed_error)
|
93
|
+
# This catches errors within GLCommand::Context and prevents self referential error display
|
94
|
+
# $ERROR_INFO (aka $!), stores the last Ruby error
|
95
|
+
@full_error_message ||= $ERROR_INFO.to_s if $ERROR_INFO.to_s.include?('for <GLCommand::Context')
|
96
|
+
# If something raised ActiveRecord::RecordInvalid, assign its errors to #errors
|
97
|
+
merge_errors(passed_error.record.errors) if
|
98
|
+
passed_error.is_a?(ActiveRecord::RecordInvalid) && defined?(passed_error.record.errors)
|
99
|
+
# Return a new error if it's an error (rather than the class)
|
100
|
+
passed_error.is_a?(Class) ? passed_error.new(@full_error_message) : passed_error
|
101
|
+
elsif errors.present? # check for validation errors
|
102
|
+
# Assign ActiveRecord::RecordInvalid if validatable error
|
103
|
+
ActiveRecord::RecordInvalid.new(@callable)
|
104
|
+
else
|
105
|
+
@full_error_message ||= passed_error if passed_error.present?
|
106
|
+
GLCommand::StopAndFail.new(passed_error || @full_error_message)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
110
|
+
|
111
|
+
def no_notifiable_error_to_raise
|
112
|
+
if no_notify? && !inside_no_notify_error?
|
113
|
+
GLCommand::CommandNoNotifyError.new(full_error_message)
|
114
|
+
else
|
115
|
+
error
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def exception?(passed_error)
|
122
|
+
passed_error.is_a?(Exception) ||
|
123
|
+
(passed_error.respond_to?(:ancestors) && passed_error.ancestors.include?(Exception))
|
124
|
+
end
|
125
|
+
|
126
|
+
def inside_no_notify_error?
|
127
|
+
@error.present? && @error.respond_to?(:cause) && @error.cause.present? &&
|
128
|
+
@error.cause.is_a?(GLCommand::CommandNoNotifyError)
|
129
|
+
end
|
130
|
+
|
131
|
+
def merge_errors(new_errors)
|
132
|
+
# When merging the errors, don't add duplicate errors
|
133
|
+
new_errors.each do |new_error|
|
134
|
+
errors.import(new_error) unless errors&.full_messages&.include?(new_error.full_message)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# Overridden in ChainableContext
|
139
|
+
def assignable_parameters
|
140
|
+
@klass.arguments_and_returns
|
141
|
+
end
|
142
|
+
|
143
|
+
# Overridden in ChainableContext
|
144
|
+
def assign_parameter_val(arg, val, skip_unknown_parameters:)
|
145
|
+
if assignable_parameters.include?(arg)
|
146
|
+
# if required because chain_arguments_and_returns are assignable_parameters
|
147
|
+
send(:"#{arg}=", val) if @klass.arguments_and_returns.include?(arg)
|
148
|
+
elsif !skip_unknown_parameters
|
149
|
+
raise ArgumentError, "Unknown argument or return attribute: '#{arg}'"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# Overridden in ChainableContext
|
154
|
+
def inspect_values
|
155
|
+
[
|
156
|
+
"error=#{success? ? 'nil' : full_error_message}",
|
157
|
+
"success=#{success?}",
|
158
|
+
"arguments={#{::GLCommand::ContextInspect.hash_params(arguments)}}",
|
159
|
+
"returns={#{::GLCommand::ContextInspect.hash_params(returns)}}"
|
160
|
+
]
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module GLCommand
|
2
|
+
class ContextInspect
|
3
|
+
def self.error(error_obj)
|
4
|
+
return '' if error_obj.blank?
|
5
|
+
|
6
|
+
error_obj.is_a?(Array) ? error_obj.uniq.join(', ') : error_obj.to_s
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.hash_params(hash)
|
10
|
+
hash.map do |key, value|
|
11
|
+
value_s =
|
12
|
+
if value.nil?
|
13
|
+
'nil'
|
14
|
+
elsif value.respond_to?(:to_sql)
|
15
|
+
object_param_as_sql(value)
|
16
|
+
elsif value.respond_to?(:uuid)
|
17
|
+
object_param_with_id(value, :uuid)
|
18
|
+
elsif value.respond_to?(:id)
|
19
|
+
object_param_with_id(value, :id)
|
20
|
+
else
|
21
|
+
value
|
22
|
+
end
|
23
|
+
"#{key}: #{value_s}"
|
24
|
+
end.join(', ')
|
25
|
+
end
|
26
|
+
|
27
|
+
# Active record objects can be really big - rather than rendering the whole object, just show the ID
|
28
|
+
private_class_method def self.object_param_with_id(obj, key)
|
29
|
+
obj_id = obj.send(key)
|
30
|
+
id_value = obj_id.is_a?(Integer) ? obj_id : "\"#{obj_id}\""
|
31
|
+
"#<#{obj.class.name} #{key}=#{id_value}>"
|
32
|
+
end
|
33
|
+
|
34
|
+
private_class_method def self.object_param_as_sql(obj)
|
35
|
+
count =
|
36
|
+
if obj.respond_to?(:count)
|
37
|
+
obj.count
|
38
|
+
else
|
39
|
+
'N/A'
|
40
|
+
end
|
41
|
+
"#<#{obj.class.name} count=#{count}, sql=\"#{obj.to_sql}\">"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/concern'
|
4
|
+
require 'active_model'
|
5
|
+
|
6
|
+
module GLCommand
|
7
|
+
module Validatable
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
# In reality, validations should be in the context
|
10
|
+
# BUT, since we don't write context classes,
|
11
|
+
# we need to have everything about validations get passed
|
12
|
+
include ActiveModel::Validations
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
def i18n_scope
|
16
|
+
:activerecord
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# This is called in Callable.perform_call (via validate_validatable!)
|
21
|
+
def validatable_valid?
|
22
|
+
context.assign_callable(self) # used by validatable
|
23
|
+
validate
|
24
|
+
errors.none?
|
25
|
+
end
|
26
|
+
|
27
|
+
def validate_validatable!
|
28
|
+
return true if validatable_valid?
|
29
|
+
|
30
|
+
stop_and_fail!(ActiveRecord::RecordInvalid.new(self), no_notify: true)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/lib/gl_command.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gl_command/context_inspect'
|
4
|
+
require 'gl_command/context'
|
5
|
+
require 'gl_command/chainable_context'
|
6
|
+
require 'gl_command/callable'
|
7
|
+
require 'gl_command/chainable'
|
8
|
+
|
9
|
+
module GLCommand
|
10
|
+
class ArgumentTypeError < StandardError; end
|
11
|
+
|
12
|
+
class StopAndFail < StandardError; end
|
13
|
+
|
14
|
+
# NOTE: CommandNoNotifyError should not be the final error raised,
|
15
|
+
# It should be the #cause for the final error raised. See callable#handle_failure
|
16
|
+
class CommandNoNotifyError < StandardError; end
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gl_command
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Give Lively
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-08-19 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 3.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 3.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: gl_exception_notifier
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.0.2
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 1.0.2
|
41
|
+
description:
|
42
|
+
email:
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files:
|
46
|
+
- README.md
|
47
|
+
files:
|
48
|
+
- LICENSE
|
49
|
+
- README.md
|
50
|
+
- gl_command.gemspec
|
51
|
+
- lib/gl_command.rb
|
52
|
+
- lib/gl_command/callable.rb
|
53
|
+
- lib/gl_command/chainable.rb
|
54
|
+
- lib/gl_command/chainable_context.rb
|
55
|
+
- lib/gl_command/context.rb
|
56
|
+
- lib/gl_command/context_inspect.rb
|
57
|
+
- lib/gl_command/validatable.rb
|
58
|
+
- lib/gl_command/version.rb
|
59
|
+
homepage: https://github.com/givelively/gl_command
|
60
|
+
licenses:
|
61
|
+
- Apache
|
62
|
+
metadata:
|
63
|
+
rubygems_mfa_required: 'true'
|
64
|
+
post_install_message:
|
65
|
+
rdoc_options: []
|
66
|
+
require_paths:
|
67
|
+
- lib
|
68
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - ">="
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '3.1'
|
73
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
requirements: []
|
79
|
+
rubygems_version: 3.4.19
|
80
|
+
signing_key:
|
81
|
+
specification_version: 4
|
82
|
+
summary: Give Lively Commands
|
83
|
+
test_files: []
|