good 0.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.
Files changed (9) hide show
  1. data/.gitignore +17 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +234 -0
  5. data/Rakefile +1 -0
  6. data/good.gemspec +23 -0
  7. data/lib/good.rb +71 -0
  8. data/spec/good_spec.rb +199 -0
  9. metadata +103 -0
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in good.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Rafer Hazen
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # Good
2
+
3
+ 2 little things that make writing good Ruby programs a little easier.
4
+
5
+ 1. `Good::Value` is a class generator for simple, pleasant [Value objects](http://en.wikipedia.org/wiki/Value_object).
6
+
7
+ 2. `Good::Record` is a class generator for simple, pleasant [Record objects](http://en.wikipedia.org/wiki/Record_(computer_science) "Record Objects"). They're a lot like `Struct`.
8
+
9
+ Both are used the same way same way, like this:
10
+
11
+ ```ruby
12
+ class Person < Good::Value.new(:name, :age)
13
+ end
14
+ ```
15
+
16
+ or like this if you prefer:
17
+
18
+ ```ruby
19
+ Person = Good::Value.new(:name, :age)
20
+ end
21
+ ```
22
+
23
+ Now, we can create a `Person`:
24
+
25
+ ```ruby
26
+ person = Person.new(:name => "Mrs. Betty Slocombe", :age => 46)
27
+ ```
28
+
29
+ and ask it about itself:
30
+
31
+ ```ruby
32
+ person.name # => "Mrs. Betty Slocombe"
33
+ person.age # => 46
34
+ ```
35
+
36
+ `Good::Value` objects are immutable:
37
+
38
+ ```ruby
39
+ person.name = "Captain Stephen Peacock" #=> NoMethodError: undefined method `name=' for ...
40
+ ```
41
+
42
+ But don't worry, you can still get what you want:
43
+
44
+ ```ruby
45
+ person = person.merge(:name => "Captain Stephen Peacock")
46
+ person.name # => "Captain Stephen Peacock"
47
+ ```
48
+
49
+ Most of the time immutable is good. If you don't want that though, try
50
+ `Good::Record`:
51
+
52
+ ```ruby
53
+ class Person < Good::Record.new(:name, :age)
54
+ end
55
+ ```
56
+
57
+ Now we can mutate the object:
58
+
59
+ ```ruby
60
+ person = Person.new(:name => "Mrs. Betty Slocombe", :age => 46)
61
+ person.age = 30
62
+ person.age # => 30
63
+ ```
64
+
65
+ Except for mutability `Good::Value` and `Good::Record` have the same interface.
66
+
67
+ Don't forget, `Good::Value` and `Good::Record` are just regular Ruby objects,
68
+ so they get to have methods just like everybody else:
69
+
70
+ ```ruby
71
+ Person < Good::Value.new(:name, :age)
72
+ def introduction
73
+ "My name is #{name} and I'm #{age} years old"
74
+ end
75
+ end
76
+ ```
77
+
78
+ Also, classes created with `Good::Value` and `Good::Record` have reasonable
79
+ implmentations of `#==`, `#eql?` and `#hash`.
80
+
81
+ ## Bonus Features
82
+
83
+ You can ask `Good::Value` and `Good::Record` a little about their structure:
84
+
85
+ ```ruby
86
+ person.new(:name => "Miss Brahms", :age => 30)
87
+
88
+ Person::MEMBERS # => [:name, :age]
89
+ person.members # => [:name, :age]
90
+ person.values # => ["Miss Brahms", 30]
91
+ person.attributes # => {:name => "Miss Mrahms", :age => 30}
92
+ ```
93
+
94
+ You can call `Person.coerce` to coerce input to a `Person` in the follwoing
95
+ ways:
96
+
97
+ ```ruby
98
+ # from a Hash (creates a new Person)
99
+ Person.coerce(:name => "Mr. Ernest Grainge") # => #<Person:0x007fbe9121d048 @name="Mr. Ernest Grainge">
100
+
101
+ # from a Person (returns the input unmodified)
102
+ person = Person.new(:name => "Mr. Cuthbert Rumbold")
103
+ Person.coerce(person) # => #<Person:0x007fbe920270f8 @name="Mr. Cuthbert Rumbold">
104
+
105
+ # from something wrong
106
+ Person.coerce("WRONG") # => TypeError: Unable to coerce String into Person
107
+ ```
108
+
109
+ `.coerce` is particularly useful at code boundaries. It allows clients to pass
110
+ options as a hash if they want to, while allowing you to use the type you
111
+ expected confidently (because blatantly incorrect values raise a `TypeError`).
112
+
113
+ ## Motivation
114
+
115
+ Why does the world need this?
116
+
117
+ ### `Good` vs Regular Ruby Objects
118
+
119
+ Creating value classes is a good idea. Properly used, they make testing easier,
120
+ help with separation of concerns and make interfaces more apparent. In Ruby, we
121
+ like to do stuff with as little ceremony as possible. So what's wrong with a
122
+ regular class:
123
+
124
+ ```ruby
125
+ class Person
126
+ attr_accessor :age, :name
127
+ end
128
+ ```
129
+
130
+ Nothing, really. The only problem is that, in order to get an object that's
131
+ easy to work with, you'll probably want to implement `#initialize`, `#==`,
132
+ `#eql?` and `#hash`. This isn't really so bad, but if you want to quickly create
133
+ a number of these classes, the boilerplate code gets heavy pretty quickly. Plus
134
+ you'll probably do it wrong the first time (I certainly did).
135
+
136
+ It's worth noting that `Good` in no way seeks to become the foundation of your
137
+ domain model. The second a class outgrows it's `Good::Value` or `Good::Record`
138
+ roots, by all means you should remove `Good` from the picture and rely on pure
139
+ Ruby classes instead. `Good` helps you get started quickly by making a
140
+ particular pattern easy, but when your classes get more mature, it's time for
141
+ `Good` to go.
142
+
143
+ ### `Good` vs `Hash`
144
+
145
+ In general, passing hashes around in your application is a bad idea, unless the
146
+ data they contain is truly unstructured. Since you can't add methods to hashes
147
+ (unless you subclass them, which is perhaps its own variety of bad idea), a
148
+ little bit of the logic to deal with these "structured" hashes gets spread
149
+ around a lot of places.
150
+
151
+ With a hash it can also be hard to figure out exactly what it is expected to
152
+ contain. In many cases the passing of a hash with specific expectations about
153
+ its contents is an indication that you're missing a class. Hopefully, prudent
154
+ application of `Good::Value` and `Good::Record` will allow you extract that
155
+ class more quickly, with minimal extra work.
156
+
157
+ However, this is not to say that you should not use a hash at the boundary
158
+ between client and library, or between various modules in your system. For
159
+ example, say we've got an `Authenticator` class that takes a user's credentials:
160
+
161
+ ```ruby
162
+ class Authenticator
163
+ def initialize(credentials)
164
+ username = credentials[:username]
165
+ password = credentials[:password]
166
+ end
167
+
168
+ def authentic?
169
+ ...
170
+ end
171
+ end
172
+ ```
173
+
174
+ This is a perfectly reasonable interface for a client to use:
175
+
176
+ ```ruby
177
+ authenticator = Authenticator.new({
178
+ :username => "m.grace@gracebrothers.com",
179
+ :password => "rUbngS3rvd"
180
+ })
181
+
182
+ login if authenticator.authentic?
183
+ ```
184
+
185
+ The following implementation maintains the same interface for the client and
186
+ adds very little code:
187
+
188
+ ```ruby
189
+ class Authenticator
190
+ Credentials = Good::Value.new(:username, :password)
191
+
192
+ def initialize(credentials)
193
+ @credentials = Credentials.coerce(credentials)
194
+ end
195
+
196
+ def authentic?
197
+ ...
198
+ end
199
+ end
200
+ ```
201
+
202
+ Say now that the the Authenticator needs to pass the user's credentials to
203
+ another component (to log the attempt, for example), we are now in the enviable
204
+ position of having an object, with a well defined interface to pass around -
205
+ not a hash with implicit assumptions about its contents. Further, because of
206
+ the `.coerce` method we can now accept a hash at the boundary or a fully formed
207
+ `Credentials` object, it makes no difference to the `Authenticator`.
208
+
209
+ This evolulution seems fairly common. To solve an immediate problem, a new
210
+ `Good::Value` class is created inside the namespace of an existing class, which
211
+ is at first desirable because it does not inflict this abstraction externally.
212
+ Then, as the class begins to interact with other compontents in the system,
213
+ this previously internal class can be made external and evolved into it's own
214
+ fully fledged domain object (perhaps shedding `Good` in the process). When you
215
+ start with a hash, it can be harder to spot the "missing" class.
216
+
217
+ ## Installation
218
+
219
+ Add this line to your application's Gemfile:
220
+
221
+ gem 'good'
222
+
223
+ And then execute:
224
+
225
+ $ bundle
226
+
227
+ Or install it yourself as:
228
+
229
+ $ gem install good
230
+
231
+ ## Tests
232
+
233
+ bundle && bundle exec rake
234
+
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/good.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'good'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "good"
8
+ spec.version = Good::VERSION
9
+ spec.authors = ["Rafer Hazen"]
10
+ spec.email = ["rafer@ralua.com"]
11
+ spec.summary = %q{Good::Value and Good::Record}
12
+ spec.homepage = ""
13
+ spec.license = "MIT"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0")
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_development_dependency "rspec", "~> 2.0 "
21
+ spec.add_development_dependency "bundler", "~> 1.5"
22
+ spec.add_development_dependency "rake"
23
+ end
data/lib/good.rb ADDED
@@ -0,0 +1,71 @@
1
+ class Good
2
+ VERSION = "0.1.0"
3
+
4
+ class Value
5
+ def self.new(*members, &block)
6
+ Good.generate(false, *members, &block)
7
+ end
8
+ end
9
+
10
+ class Record
11
+ def self.new(*members, &block)
12
+ Good.generate(true, *members, &block)
13
+ end
14
+ end
15
+
16
+ def self.generate(mutable, *members, &block)
17
+ Class.new do
18
+
19
+ mutable ? attr_accessor(*members) : attr_reader(*members)
20
+
21
+ const_set(:MEMBERS, members.dup.freeze)
22
+
23
+ def self.coerce(coercable)
24
+ case coercable
25
+ when self then coercable
26
+ when Hash then new(coercable)
27
+ else raise TypeError, "Unable to coerce #{coercable.class} into #{self}"
28
+ end
29
+ end
30
+
31
+ define_method(:initialize) do |attributes={}|
32
+ if mutable
33
+ attributes.each { |k, v| send("#{k}=", v) }
34
+ else
35
+ attributes.each { |k, v| instance_variable_set(:"@#{k}", v) }
36
+ end
37
+ end
38
+
39
+ def attributes
40
+ {}.tap { |h| self.class::MEMBERS.each { |m| h[m] = send(m) } }
41
+ end
42
+
43
+ def members
44
+ self.class::MEMBERS.dup
45
+ end
46
+
47
+ def values
48
+ self.class::MEMBERS.map { |m| send(m) }
49
+ end
50
+
51
+ def merge(attributes={})
52
+ self.class.new(self.attributes.merge(attributes))
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(self.class) && attributes == other.attributes
57
+ end
58
+
59
+ def eql?(other)
60
+ self == other
61
+ end
62
+
63
+ def hash
64
+ attributes.hash
65
+ end
66
+
67
+ class_eval(&block) if block
68
+ end
69
+ end
70
+ end
71
+
data/spec/good_spec.rb ADDED
@@ -0,0 +1,199 @@
1
+ require "bundler/setup"
2
+
3
+ shared_examples :good do
4
+ before do
5
+ class Person < described_class.new(:name, :age)
6
+ end
7
+ end
8
+
9
+ after do
10
+ Object.send(:remove_const, :Person)
11
+ end
12
+
13
+ describe "#initialize" do
14
+ it "accepts values via hash in the constructor" do
15
+ person = Person.new(:name => "Bob")
16
+ expect(person.name).to eq("Bob")
17
+ end
18
+
19
+ it "accepts string keys" do
20
+ person = Person.new("name" => "Bob")
21
+ expect(person.name).to eq("Bob")
22
+ end
23
+
24
+ it "allows 0 argument construction" do
25
+ person = Person.new
26
+ end
27
+ end
28
+
29
+ describe "#==" do
30
+ it "is true if all the parameters are ==" do
31
+ bob_1 = Person.new(:name => "Bob", :age => 50)
32
+ bob_2 = Person.new(:name => "Bob", :age => 50)
33
+
34
+ expect(bob_1).to eq(bob_2)
35
+ end
36
+
37
+ it "is false if any attributes are not #==" do
38
+ bob = Person.new(:name => "Bob", :age => 50)
39
+ ted = Person.new(:name => "Ted", :age => 50)
40
+
41
+ expect(bob).not_to eq(ted)
42
+ end
43
+
44
+ it "is false if the other object is not of the same class" do
45
+ bob = Person.new(:name => "Bob", :age => 50)
46
+ alien_bob = described_class.new(:name, :age).new(:name => "Bob", :age => 50)
47
+
48
+ expect(bob).not_to eq(alien_bob)
49
+ end
50
+ end
51
+
52
+ describe "#eql" do
53
+ it "is true if all the parameters are ==" do
54
+ bob_1 = Person.new(:name => "Bob", :age => 50)
55
+ bob_2 = Person.new(:name => "Bob", :age => 50)
56
+
57
+ expect(bob_1).to eql(bob_2)
58
+ end
59
+
60
+ it "is false if any attributes are not #==" do
61
+ bob = Person.new(:name => "Bob", :age => 50)
62
+ ted = Person.new(:name => "Ted", :age => 50)
63
+
64
+ expect(bob).not_to eql(ted)
65
+ end
66
+
67
+ it "is false if the other object is not of the same class" do
68
+ bob = Person.new(:name => "Bob", :age => 50)
69
+ alien_bob = Struct.new(:name, :age).new("Bob", 50)
70
+
71
+ expect(bob).not_to eql(alien_bob)
72
+ end
73
+ end
74
+
75
+ describe "#hash" do
76
+ it "is stable" do
77
+ bob_1 = Person.new(:name => "Bob")
78
+ bob_2 = Person.new(:name => "Bob")
79
+
80
+ expect(bob_1.hash).to eq(bob_2.hash)
81
+ end
82
+
83
+ it "varies with the parameters" do
84
+ bob = Person.new(:name => "Bob", :age => 50)
85
+ ted = Person.new(:name => "Ted", :age => 50)
86
+
87
+ expect(bob.hash).not_to eql(ted.hash)
88
+ end
89
+ end
90
+
91
+ describe "::MEMBERS" do
92
+ it "is the list of member variables" do
93
+ expect(Person::MEMBERS).to eq([:name, :age])
94
+ end
95
+
96
+ it "is frozen" do
97
+ expect { Person::MEMBERS << :height }.to raise_error(/can't modify frozen/)
98
+ end
99
+ end
100
+
101
+ describe "#members" do
102
+ it "is the list of member variables" do
103
+ person = Person.new
104
+ expect(person.members).to eq([:name, :age])
105
+ end
106
+
107
+ it "is modifiable without affecting the original members" do
108
+ person = Person.new
109
+ person.members << :height
110
+ expect(person.members).to eq([:name, :age])
111
+ end
112
+ end
113
+
114
+ describe "#values" do
115
+ it "is the list of values (in the same order as the #members)" do
116
+ person = Person.new(:age => 50, :name => "BOB")
117
+ expect(person.values).to eq(["BOB", 50])
118
+ end
119
+ end
120
+
121
+ describe "#attributes" do
122
+ it "is a hash of the attributes (with symbol keys)" do
123
+ person = Person.new(:name => "Bob", :age => 50)
124
+ expect(person.attributes).to eq(:name => "Bob", :age => 50)
125
+ end
126
+ end
127
+
128
+ describe "#merge" do
129
+ it "returns an object with the given properties modified" do
130
+ young = Person.new(:name => "Bob", :age => 50)
131
+ old = young.merge(:age => 51)
132
+
133
+ expect(old.name).to eq("Bob")
134
+ expect(old.age).to eq(51)
135
+ end
136
+
137
+ it "does not mutate the old object" do
138
+ person = Person.new(:name => "Bob", :age => 50)
139
+ person.merge(:age => 51)
140
+
141
+ expect(person.age).to eq(50)
142
+ end
143
+
144
+ it "accepts 0 arguments" do
145
+ person = Person.new
146
+ expect(person.merge).not_to be(person)
147
+ end
148
+ end
149
+
150
+ describe ".coerce" do
151
+ it "returns the input unmodified if it is already an instance of the struct" do
152
+ person = Person.new
153
+ expect(Person.coerce(person)).to be(person)
154
+ end
155
+
156
+ it "initializes a new instance if the input is a hash" do
157
+ person = Person.coerce({:name => "Bob"})
158
+ expect(person).to eq(Person.new(:name => "Bob"))
159
+ end
160
+
161
+ it "raises a TypeError otherwise" do
162
+ expect { Person.coerce("15 lbs of squirrel fur") }.to raise_error(TypeError)
163
+ end
164
+ end
165
+
166
+ describe "block construction" do
167
+ let(:car_klass) do
168
+ described_class.new(:wheels) do
169
+ def drive
170
+ "Driving with all #{wheels} wheels!"
171
+ end
172
+ end
173
+ end
174
+
175
+ it "allows definition of methods" do
176
+ car = car_klass.new(:wheels => 4)
177
+ expect(car.drive).to eq("Driving with all 4 wheels!")
178
+ end
179
+ end
180
+ end
181
+
182
+ describe Good::Value do
183
+ include_examples(:good)
184
+
185
+ it "is immutable" do
186
+ person = Person.new
187
+ expect { person.name = "Bob" }.to raise_error(NoMethodError)
188
+ end
189
+ end
190
+
191
+ describe Good::Record do
192
+ include_examples(:good)
193
+
194
+ it "is mutable" do
195
+ person = Person.new
196
+ expect { person.name = "Bob" }.to change { person.name }.to("Bob")
197
+ end
198
+ end
199
+
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: good
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Rafer Hazen
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2014-05-15 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: bundler
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: '1.5'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: '1.5'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ description:
63
+ email:
64
+ - rafer@ralua.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - .gitignore
70
+ - Gemfile
71
+ - LICENSE.txt
72
+ - README.md
73
+ - Rakefile
74
+ - good.gemspec
75
+ - lib/good.rb
76
+ - spec/good_spec.rb
77
+ homepage: ''
78
+ licenses:
79
+ - MIT
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ! '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ none: false
92
+ requirements:
93
+ - - ! '>='
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubyforge_project:
98
+ rubygems_version: 1.8.23
99
+ signing_key:
100
+ specification_version: 3
101
+ summary: Good::Value and Good::Record
102
+ test_files:
103
+ - spec/good_spec.rb