Skip to content

Commit 145fadd

Browse files
author
Jonah
committed
Initial commit
0 parents  commit 145fadd

6 files changed

+314
-0
lines changed

Gemfile

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
source 'https://rubygems.org'
2+
3+
gemspec
4+
5+
group :test do
6+
gem 'rspec', '~> 3.8'
7+
end

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2018 Jonah
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Install
2+
3+
```
4+
gem install command_class
5+
```
6+
7+
# Example Use
8+
9+
1. Define your command object class. This is a longish but real-worldish example:
10+
11+
```ruby
12+
CreateUser = CommandClass.new(
13+
dependencies: {user_repo: UserRepo, email_service: MyEmailService},
14+
inputs: [:name, :email, :password]
15+
) do
16+
17+
def call
18+
validate_input
19+
ensure_unique_email
20+
insert_user
21+
send_confirmation
22+
end
23+
24+
private
25+
26+
def validate_input
27+
validate_name
28+
validate_email
29+
validate_password
30+
end
31+
32+
def ensure_unique_email
33+
email_exists = @user_repo.find_by_email(@email)
34+
raise Errors::EmailAlreadyExists if email_exists
35+
end
36+
37+
def insert_user
38+
@user_repo.insert(name: @name, email: @email, password: @password)
39+
end
40+
41+
def send_confirmation
42+
@email_service.send_signup_confirmation(name: @name, email: @email)
43+
end
44+
45+
def validate_name
46+
valid = @name.size > 1
47+
raise Errors::InvalidName unless valid
48+
end
49+
50+
def validate_email
51+
valid = @email =~ /@/
52+
raise Errors::InvalidEmail unless valid
53+
end
54+
55+
def validate_password
56+
valid = @password.size > 5
57+
raise Errors::InvalidPassword unless valid
58+
end
59+
60+
end
61+
```
62+
63+
2. Create your command object itself:
64+
65+
```ruby
66+
create_user = CreateUser.new
67+
```
68+
**NOTE:** Here, alternatively, we can inject dependencies other than the default ones, which vastly improves tests. See the specs for examples of this.
69+
70+
3. Run the command object:
71+
72+
```ruby
73+
create_user.(name: valid_name, email: valid_email, password: valid_pw)
74+
```
75+
76+
# Motivation
77+
78+
On the benefits of Functional Command Objects:
79+
80+
https://www.icelab.com.au/notes/functional-command-objects-in-ruby/
81+
82+
For a more complex, but also more fully-featured version of this ideas, see:
83+
84+
https://dry-rb.org/gems/dry-transaction/
85+
86+
More to come...

command_class.gemspec

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Gem::Specification.new do |s|
2+
s.name = 'command_class'
3+
s.authors = ['Jonah Goldstein']
4+
s.homepage = 'https://github.com/jonahx/command_class'
5+
s.summary = 'Create functional command objects without boilerplate'
6+
s.version = '0.0.1'
7+
s.files = ["lib/command_class.rb"]
8+
s.test_files = ["spec/command_class_spec.rb"]
9+
s.license = 'MIT'
10+
end

lib/command_class.rb

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
class CommandClass
2+
def self.new(dependencies:, inputs:, &blk)
3+
cmd_cls = Class.new
4+
cmd_cls.const_set('DEFAULT_DEPS', dependencies)
5+
6+
cmd_cls.class_eval <<~RUBY
7+
def initialize(**passed_deps)
8+
deps = DEFAULT_DEPS.merge(passed_deps)
9+
deps.each { |name, val| instance_variable_set('@' + name.to_s, val) }
10+
end
11+
12+
def call(#{cmd_call_signature(inputs)})
13+
Call.new(#{call_ctor_args(dependencies, inputs)}).()
14+
end
15+
RUBY
16+
17+
call_class = Class.new(cmd_cls, &blk)
18+
call_class.class_eval <<~RUBY
19+
def initialize(#{call_ctor_sig(dependencies, inputs)})
20+
#{set_input_attrs(dependencies, inputs)}
21+
end
22+
RUBY
23+
24+
cmd_cls.const_set('Call', call_class)
25+
cmd_cls
26+
end
27+
28+
class << self
29+
30+
private
31+
32+
# TODO: allow for unnamed as well
33+
def cmd_call_signature(inputs)
34+
inputs.map {|x| "#{x}:" }.join(', ')
35+
end
36+
37+
def set_input_attrs(deps, inputs)
38+
all_args(deps, inputs).map {|x| "@#{x} = #{x}" }.join('; ')
39+
end
40+
41+
def call_ctor_sig(deps, inputs)
42+
all_args(deps, inputs).join(', ')
43+
end
44+
45+
def call_ctor_args(deps, inputs)
46+
[deps.keys.map { |x| "@#{x}" } + inputs].join(', ')
47+
end
48+
49+
def all_args(deps, inputs)
50+
deps.keys + inputs
51+
end
52+
end
53+
end

spec/command_class_spec.rb

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
require 'rspec'
2+
require_relative"../lib/command_class"
3+
4+
# Setup classes and collaborators to test
5+
#
6+
UserRepo = Class.new
7+
MyEmailService = Class.new
8+
9+
module Errors
10+
class InvalidName < RuntimeError; end
11+
class InvalidEmail < RuntimeError; end
12+
class InvalidPassword < RuntimeError; end
13+
class EmailAlreadyExists < RuntimeError; end
14+
end
15+
16+
CreateUser = CommandClass.new(
17+
dependencies: {user_repo: UserRepo, email_service: MyEmailService},
18+
inputs: [:name, :email, :password]
19+
) do
20+
21+
def call
22+
validate_input
23+
ensure_unique_email
24+
insert_user
25+
send_confirmation
26+
end
27+
28+
private
29+
30+
def validate_input
31+
validate_name
32+
validate_email
33+
validate_password
34+
end
35+
36+
def ensure_unique_email
37+
email_exists = @user_repo.find_by_email(@email)
38+
raise Errors::EmailAlreadyExists if email_exists
39+
end
40+
41+
def insert_user
42+
@user_repo.insert(name: @name, email: @email, password: @password)
43+
end
44+
45+
def send_confirmation
46+
@email_service.send_signup_confirmation(name: @name, email: @email)
47+
end
48+
49+
def validate_name
50+
valid = @name.size > 1
51+
raise Errors::InvalidName unless valid
52+
end
53+
54+
def validate_email
55+
valid = @email =~ /@/
56+
raise Errors::InvalidEmail unless valid
57+
end
58+
59+
def validate_password
60+
valid = @password.size > 5
61+
raise Errors::InvalidPassword unless valid
62+
end
63+
64+
end
65+
66+
# THE TESTS THEMSELVES
67+
#
68+
describe CommandClass do
69+
70+
context "full CreateUser example" do
71+
let(:email_svc) { spy('email') }
72+
let(:user_repo) { spy('user_repo') }
73+
let(:valid_name) { 'John' }
74+
let(:valid_email) { '[email protected]' }
75+
let(:valid_pw) { 'secret' }
76+
77+
describe "happy path" do
78+
let(:happy_repo) do
79+
user_repo.tap do |x|
80+
allow(x).to receive(:find_by_email).and_return(nil)
81+
end
82+
end
83+
84+
subject(:create_user) do
85+
CreateUser.new(user_repo: happy_repo, email_service: email_svc)
86+
end
87+
88+
it 'inserts the user into the db' do
89+
create_user.(name: valid_name, email: valid_email, password: valid_pw)
90+
expect(user_repo).to have_received(:insert)
91+
end
92+
93+
it 'sends the confirmation email' do
94+
create_user.(name: valid_name, email: valid_email, password: valid_pw)
95+
expect(email_svc).to have_received(:send_signup_confirmation)
96+
end
97+
98+
end
99+
100+
describe "invalid user input" do
101+
subject(:create_user) do
102+
CreateUser.new(user_repo: user_repo, email_service: email_svc)
103+
end
104+
105+
it 'errors for a short name' do
106+
expect do
107+
create_user.(name: 'x', email: valid_email, password: valid_pw)
108+
end.to raise_error(Errors::InvalidName)
109+
end
110+
111+
it 'errors on an invalid email' do
112+
expect do
113+
create_user.(name: valid_email, email: 'bad_email', password: valid_pw)
114+
end.to raise_error(Errors::InvalidEmail)
115+
end
116+
end
117+
118+
describe "existing email" do
119+
let(:repo_with_email) do
120+
user_repo.tap do |x|
121+
allow(x).to receive(:find_by_email).and_return('user obj')
122+
end
123+
end
124+
125+
subject(:create_user) do
126+
CreateUser.new(user_repo: repo_with_email, email_service: email_svc)
127+
end
128+
129+
it 'errors' do
130+
expect do
131+
create_user.(name: valid_name, email: valid_email, password: valid_pw)
132+
end.to raise_error(Errors::EmailAlreadyExists)
133+
end
134+
end
135+
end
136+
137+
end

0 commit comments

Comments
 (0)