skywalker 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +185 -6
- data/lib/skywalker/command.rb +20 -5
- data/lib/skywalker/version.rb +1 -1
- data/lib/skywalker.rb +0 -1
- data/skywalker.gemspec +2 -0
- data/spec/lib/skywalker/command_spec.rb +48 -11
- data/spec/spec_helper.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3f6ceac6b8264295e36a783bfb010c99b1caa549
|
4
|
+
data.tar.gz: 2705c927542812c9033a49957d37ed00a7a9cddf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 93acfda6e3a9adf0516d804f206d234e5cf14efe338eb4abeef2bf810917e1bcdd9638c04545aad6823b4f5b275c48fafe40f93dea498c9161f800bbdc5fa6f7
|
7
|
+
data.tar.gz: 6b1a5df15debb17a660a2fa92eb95ce14b8b555a056768b905245ac1491f46d6ea398ef5ab50f9221deef18ff736b3c9ceb51a47bdc0ef7931994b6479b69c95
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
All commits by Rob Yurkowski unless otherwise noted.
|
2
|
+
|
3
|
+
|
4
|
+
## 1.1.0 (2014-11-03)
|
5
|
+
|
6
|
+
- Expanded documentation.
|
7
|
+
- Allow `on_success` and `on_failure` callbacks to be optional.
|
8
|
+
- Allow passing arbitrary values to command instantiation.
|
9
|
+
- Adds this changelog.
|
10
|
+
|
11
|
+
## 1.0.0 (2014-11-02)
|
12
|
+
|
13
|
+
- Initial creation.
|
data/README.md
CHANGED
@@ -4,8 +4,8 @@ Skywalker is a gem that provides a simple command pattern for applications that
|
|
4
4
|
|
5
5
|
## Why Skywalker?
|
6
6
|
|
7
|
-
It's impossible to come up with a single-word
|
8
|
-
achieve
|
7
|
+
It's impossible to come up with a single-word name for a gem about commands that's at least marginally
|
8
|
+
witty. If you can't achieve wit or cleverness, at least achieve topicality, right?
|
9
9
|
|
10
10
|
## What is a command?
|
11
11
|
|
@@ -34,7 +34,19 @@ having to know its internals. Standard caveats apply, but if you have a `CreateG
|
|
34
34
|
command, you should be able to infer that calling the command with the correct arguments
|
35
35
|
will produce the expected result.
|
36
36
|
|
37
|
-
###
|
37
|
+
### Knowledge of Results Without Knowledge of Response
|
38
|
+
|
39
|
+
A command prescriptively takes callbacks or `#call`able objects, which can be called
|
40
|
+
depending on the result of the command. By default, `Skywalker::Command` can handle
|
41
|
+
an `on_success` and an `on_failure` callback, which are called after their respective
|
42
|
+
results. You can define these in your controllers, which lets you run the same command
|
43
|
+
but respond in unique ways, and keeps controller concerns inside the controller.
|
44
|
+
|
45
|
+
You can also easily override which callbacks are run. Need to run a different callback
|
46
|
+
if `request.xhr?`? Simply override `run_success_callbacks` and `run_failure_callbacks`
|
47
|
+
and call your own.
|
48
|
+
|
49
|
+
### A Gateway to Harder Architectures
|
38
50
|
|
39
51
|
It's not hard to create an `Event` class and step up toward full event sourcing, or to
|
40
52
|
go a bit further and implement full CQRS. This is the architectural pattern your parents
|
@@ -58,9 +70,153 @@ Or install it yourself as:
|
|
58
70
|
|
59
71
|
## Usage
|
60
72
|
|
73
|
+
Let's talk about a situation where you're creating a group and sending an email inside a
|
74
|
+
Rails app.
|
75
|
+
|
76
|
+
Standard operating procedure usually falls into one of two patterns, both of which are
|
77
|
+
mediocre. The first makes use of ActiveRecord callbacks:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
# app/controllers/groups_controller.rb
|
81
|
+
class GroupsController < ApplicationController
|
82
|
+
# ...
|
83
|
+
|
84
|
+
def create
|
85
|
+
@group = Group.new(params.require(:group).permit(:name))
|
86
|
+
|
87
|
+
if @group.save
|
88
|
+
redirect_to @group, notice: "Created the group!"
|
89
|
+
else
|
90
|
+
flash[:alert] = "Oh no, something went wrong!"
|
91
|
+
render :new
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
|
97
|
+
# app/models/group.rb
|
98
|
+
class Group < ActiveRecord::Base
|
99
|
+
after_create :send_notification
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def send_notification
|
104
|
+
NotificationMailer.group_created_notification(self).deliver
|
105
|
+
end
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
This might seem concise because it keeps the controller small. (Fat model,
|
110
|
+
thin controller has been a plank of Rails development for a while, but it's
|
111
|
+
slowly going away, thank heavens). But there are two problems here:
|
112
|
+
first, it introduces a point of coupling between the model and the mailer,
|
113
|
+
which not only makes testing slower, it means that these two objects are
|
114
|
+
now entwined. Create a group through the Rails console? You're sending an email with
|
115
|
+
no way to skip that. Secondly, it reduces the reasonability of the code. When you
|
116
|
+
look at the `GroupsController`, you can't suss out the fact that this sends an email.
|
117
|
+
|
118
|
+
**Moral #1: Orthogonal concerns should not be put into ActiveRecord callbacks.**
|
119
|
+
|
120
|
+
The alternative is to keep this inside the controller:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
# app/controllers/groups_controller.rb
|
124
|
+
class GroupsController < ApplicationController
|
125
|
+
# ...
|
126
|
+
|
127
|
+
def create
|
128
|
+
@group = Group.new(params.require(:group).permit(:name))
|
129
|
+
|
130
|
+
if @group.save
|
131
|
+
NotificationMailer.group_created_notification(@group).deliver
|
132
|
+
redirect_to @group, notice: "Created the group!"
|
133
|
+
else
|
134
|
+
flash[:alert] = "Oh no, something went wrong!"
|
135
|
+
render :new
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
```
|
140
|
+
|
141
|
+
This is more reasonable, but it's longer in the controller and at some point your eyes
|
142
|
+
begin to glaze over. Imagine as these orthogonal concerns grow longer and longer. Maybe
|
143
|
+
you're sending a tweet about the group, scheduling a background job to update some thumbnails,
|
144
|
+
or hitting a webhook URL. You're losing the reasonability of the code because of the detail.
|
145
|
+
|
146
|
+
Moreover, imagine that the group email being sent contains critical instructions on how
|
147
|
+
to proceed. What if `NotificationMailer` has a syntax error? The group is created, but the
|
148
|
+
mail won't be sent. Now the user hasn't gotten a good error, and your database is potentially
|
149
|
+
fouled up by half-performed requests. You can run this in a transaction, but that does not
|
150
|
+
reduce the complexity contained within the controller.
|
151
|
+
|
152
|
+
**Moral #2: Rails controllers should dispatch to application logic, and receive instructions on how to respond.**
|
153
|
+
|
154
|
+
The purpose of the command is to group orthogonal but interdependent results into logical operations. Here's how that
|
155
|
+
looks with a `Skywalker::Command`:
|
156
|
+
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
# app/controllers/groups_controller.rb
|
160
|
+
class GroupsController < ApplicationController
|
161
|
+
# ...
|
162
|
+
|
163
|
+
def create
|
164
|
+
CreateGroupCommand.call(
|
165
|
+
group: Group.new(params.require(:group).permit(:name)),
|
166
|
+
on_success: method(:on_create_success),
|
167
|
+
on_failure: method(:on_create_failure)
|
168
|
+
)
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
def on_create_success(command)
|
173
|
+
redirect_to command.group, notice: "Created the group!"
|
174
|
+
end
|
175
|
+
|
176
|
+
|
177
|
+
def on_create_failure(command)
|
178
|
+
flash[:alert] = "Oh no, something went wrong!"
|
179
|
+
@group = command.group
|
180
|
+
render :new
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
|
185
|
+
# app/commands/create_group_command.rb
|
186
|
+
class CreateGroupCommand < Skywalker::Command
|
187
|
+
def execute!
|
188
|
+
save_group!
|
189
|
+
send_notification!
|
190
|
+
end
|
191
|
+
|
192
|
+
|
193
|
+
private def save_group!
|
194
|
+
group.save!
|
195
|
+
end
|
196
|
+
|
197
|
+
|
198
|
+
private def send_notifications!
|
199
|
+
notifier.call(group).deliver
|
200
|
+
end
|
201
|
+
|
202
|
+
|
203
|
+
private def notifier
|
204
|
+
@notifier ||= NotificationsMailer.method(:group_created_notification)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
```
|
208
|
+
|
209
|
+
You can of course set up a default for Group as with `#notifier` and pass in
|
210
|
+
params only, but I find injecting a pre-constructed ActiveRecord object usually
|
211
|
+
works well.
|
212
|
+
|
213
|
+
|
214
|
+
### Basic Composition Summary
|
61
215
|
Compose your commands:
|
62
216
|
|
63
217
|
```ruby
|
218
|
+
require 'skywalker/command'
|
219
|
+
|
64
220
|
class AddGroupCommand < Skywalker::Command
|
65
221
|
def execute!
|
66
222
|
# Your transactional operations go here. No need to open a transaction.
|
@@ -73,13 +229,36 @@ Then call your commands:
|
|
73
229
|
|
74
230
|
```ruby
|
75
231
|
command = AddGroupCommand.call(
|
76
|
-
|
77
|
-
on_failure: method(:on_failure)
|
232
|
+
any_keyword_argument: "Is taken and has an attr_accessor defined for it."
|
78
233
|
)
|
79
234
|
|
80
|
-
You can pass any object responding to `#call` to the `on_success` and `on_failure` handlers, including procs, lambdas, controller methods, or other commands themselves.
|
81
235
|
```
|
82
236
|
|
237
|
+
You can pass any object responding to `#call` to the `on_success` and `on_failure` handlers, including procs, lambdas, controller methods, or other commands themselves.
|
238
|
+
|
239
|
+
### Overriding Methods
|
240
|
+
|
241
|
+
The following methods are overridable for easy customization:
|
242
|
+
|
243
|
+
- `execute!`
|
244
|
+
- Define your operations here.
|
245
|
+
- `transaction(&block)`
|
246
|
+
- Uses an `ActiveRecord::Base.transaction` by default, but can be customized. `execute!` runs inside of this.
|
247
|
+
- `confirm_success`
|
248
|
+
- Fires off callbacks on command success (i.e. non-error).
|
249
|
+
- `run_success_callbacks`
|
250
|
+
- Dictates which success callbacks are run. Defaults to `on_success` if defined.
|
251
|
+
- `confirm_failure`
|
252
|
+
- Fires off callbacks on command failure (i.e. erroneous state), and sets the exception as `command.error`.
|
253
|
+
- `run_failure_callbacks`
|
254
|
+
- Dictates which failure callbacks are run. Defaults to `on_failure` if defined.
|
255
|
+
|
256
|
+
For further reference, simply see the command file. It's less than 90 LOC and well-commented.
|
257
|
+
|
258
|
+
## Testing
|
259
|
+
|
260
|
+
To come.
|
261
|
+
|
83
262
|
## Contributing
|
84
263
|
|
85
264
|
1. Fork it ( https://github.com/robyurkowski/skywalker/fork )
|
data/lib/skywalker/command.rb
CHANGED
@@ -13,9 +13,14 @@ module Skywalker
|
|
13
13
|
################################################################################
|
14
14
|
# Instantiates command, setting all arguments.
|
15
15
|
################################################################################
|
16
|
-
def initialize(
|
17
|
-
|
18
|
-
|
16
|
+
def initialize(**args)
|
17
|
+
args.each_pair do |k, v|
|
18
|
+
singleton_class.class_eval do
|
19
|
+
send(:attr_accessor, k) unless self.respond_to? k
|
20
|
+
end
|
21
|
+
|
22
|
+
self.send("#{k}=", v)
|
23
|
+
end
|
19
24
|
end
|
20
25
|
|
21
26
|
|
@@ -57,7 +62,12 @@ module Skywalker
|
|
57
62
|
# Trigger the given callback on success
|
58
63
|
################################################################################
|
59
64
|
private def confirm_success
|
60
|
-
|
65
|
+
run_success_callbacks
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
private def run_success_callbacks
|
70
|
+
on_success.call(self) if self.respond_to?(:on_success)
|
61
71
|
end
|
62
72
|
|
63
73
|
|
@@ -66,7 +76,12 @@ module Skywalker
|
|
66
76
|
################################################################################
|
67
77
|
private def confirm_failure(error)
|
68
78
|
self.error = error
|
69
|
-
|
79
|
+
run_failure_callbacks
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
private def run_failure_callbacks
|
84
|
+
on_failure.call(self) if self.respond_to?(:on_failure)
|
70
85
|
end
|
71
86
|
end
|
72
87
|
end
|
data/lib/skywalker/version.rb
CHANGED
data/lib/skywalker.rb
CHANGED
data/skywalker.gemspec
CHANGED
@@ -18,6 +18,8 @@ Gem::Specification.new do |spec|
|
|
18
18
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
|
21
|
+
spec.required_ruby_version = '>= 2.1.2'
|
22
|
+
|
21
23
|
spec.add_dependency 'activerecord', '~> 4.1'
|
22
24
|
|
23
25
|
spec.add_development_dependency "bundler", "~> 1.7"
|
@@ -5,21 +5,20 @@ module Skywalker
|
|
5
5
|
RSpec.describe Command do
|
6
6
|
describe "convenience" do
|
7
7
|
it "provides a class call method that instantiates and calls" do
|
8
|
-
arg = 'blah'
|
9
|
-
|
10
8
|
expect(Command).to receive_message_chain('new.call')
|
11
|
-
Command.call
|
9
|
+
Command.call
|
12
10
|
end
|
13
11
|
end
|
14
12
|
|
15
13
|
|
16
14
|
describe "instantiation" do
|
17
|
-
it "accepts
|
18
|
-
expect { Command.new(
|
15
|
+
it "accepts a variable list of arguments" do
|
16
|
+
expect { Command.new(a_symbol: :my_symbol, a_string: "my string") }.not_to raise_error
|
19
17
|
end
|
20
18
|
|
21
|
-
it "
|
22
|
-
|
19
|
+
it "sets an instance variable for each argument" do
|
20
|
+
command = Command.new(a_symbol: :my_symbol)
|
21
|
+
expect(command.a_symbol).to eq(:my_symbol)
|
23
22
|
end
|
24
23
|
end
|
25
24
|
|
@@ -53,10 +52,27 @@ module Skywalker
|
|
53
52
|
command.call
|
54
53
|
end
|
55
54
|
|
56
|
-
it "
|
57
|
-
expect(
|
55
|
+
it "runs the success callbacks" do
|
56
|
+
expect(command).to receive(:run_success_callbacks)
|
58
57
|
command.call
|
59
58
|
end
|
59
|
+
|
60
|
+
describe "on_success" do
|
61
|
+
context "when on_success is defined" do
|
62
|
+
it "calls the on_success callback with itself" do
|
63
|
+
expect(on_success).to receive(:call).with(command)
|
64
|
+
command.call
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context "when on_success is not defined" do
|
69
|
+
let(:command) { Command.new }
|
70
|
+
|
71
|
+
it "does not call on_success" do
|
72
|
+
expect(command).not_to receive(:on_success)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
60
76
|
end
|
61
77
|
|
62
78
|
|
@@ -79,11 +95,32 @@ module Skywalker
|
|
79
95
|
command.call
|
80
96
|
end
|
81
97
|
|
82
|
-
it "
|
98
|
+
it "runs the failure callbacks" do
|
83
99
|
allow(command).to receive(:error=)
|
84
|
-
expect(
|
100
|
+
expect(command).to receive(:run_failure_callbacks)
|
85
101
|
command.call
|
86
102
|
end
|
103
|
+
|
104
|
+
describe "on_failure" do
|
105
|
+
before do
|
106
|
+
allow(command).to receive(:error=)
|
107
|
+
end
|
108
|
+
|
109
|
+
context "when on_failure is defined" do
|
110
|
+
it "calls the on_failure callback with itself" do
|
111
|
+
expect(on_failure).to receive(:call).with(command)
|
112
|
+
command.call
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
context "when on_failure is not defined" do
|
117
|
+
let(:command) { Command.new }
|
118
|
+
|
119
|
+
it "does not call on_failure" do
|
120
|
+
expect(command).not_to receive(:on_failure)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
87
124
|
end
|
88
125
|
end
|
89
126
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -54,7 +54,7 @@ RSpec.configure do |config|
|
|
54
54
|
|
55
55
|
# This setting enables warnings. It's recommended, but in some cases may
|
56
56
|
# be too noisy due to issues in dependencies.
|
57
|
-
config.warnings = true
|
57
|
+
# config.warnings = true
|
58
58
|
|
59
59
|
# Many RSpec users commonly either run the entire suite or an individual
|
60
60
|
# file, and it's useful to allow more verbose output when running an
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: skywalker
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rob Yurkowski
|
@@ -103,6 +103,7 @@ extra_rdoc_files: []
|
|
103
103
|
files:
|
104
104
|
- ".gitignore"
|
105
105
|
- ".rspec"
|
106
|
+
- CHANGELOG.md
|
106
107
|
- Gemfile
|
107
108
|
- LICENSE.txt
|
108
109
|
- README.md
|
@@ -125,7 +126,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
125
126
|
requirements:
|
126
127
|
- - ">="
|
127
128
|
- !ruby/object:Gem::Version
|
128
|
-
version:
|
129
|
+
version: 2.1.2
|
129
130
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
130
131
|
requirements:
|
131
132
|
- - ">="
|