enumerated_type 0.0.2 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +156 -16
- data/lib/enumerated_type.rb +32 -22
- data/lib/enumerated_type/version.rb +1 -1
- data/test/enumerated_type_spec.rb +48 -34
- metadata +5 -5
data/README.md
CHANGED
@@ -1,29 +1,169 @@
|
|
1
|
-
#
|
1
|
+
# EnumeratedType
|
2
2
|
|
3
|
-
|
3
|
+
Dead simple enumerated types for Ruby.
|
4
4
|
|
5
|
-
##
|
5
|
+
## Background
|
6
6
|
|
7
|
-
|
7
|
+
This gem implements the familiar notion of enumerated types in Ruby.
|
8
8
|
|
9
|
-
|
9
|
+
"But this is Ruby," you say, "where we haven't any use for such things." Yep. In Ruby, you can get a long way without any formalized concept of enumerated types by just using regular, boring old symbols. Let's take the fairly typical example of a "status" field on the `Job` class:
|
10
10
|
|
11
|
-
|
11
|
+
```ruby
|
12
|
+
class Job
|
13
|
+
attr_reader :status
|
12
14
|
|
13
|
-
|
15
|
+
def initialize
|
16
|
+
@status = :pending
|
17
|
+
end
|
14
18
|
|
15
|
-
|
19
|
+
def process
|
20
|
+
begin
|
21
|
+
# Do work here...
|
22
|
+
@status = :success
|
23
|
+
rescue
|
24
|
+
@status = :failure
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
16
29
|
|
17
|
-
|
30
|
+
At first pass this seems fine. Any code that needs to act based on a job's status has to have magic symbols (i.e. `job.status == :success`), but maybe that's ok for a little while. Later, though, we might want to add a little logic to the `Job`'s status, something like:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
# In a job notifier class or something
|
34
|
+
if job.status == :failure or job.status == :success
|
35
|
+
# Email user to let them know about their job
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
At this point, it's starting to feel like a little bit too much knowledge about the `Job` has slipped into other classes; any changes to the in the way status is handled in `Job` will require change in other classes because they've been exposed to the details of it's implementation. What, for example, if we want to add another "finished" state that a user should be notified of (say, `:partial_success`)? To deal with this we might create a predicate method that lets you interrogate a `Job` more abstractly about it's status:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
class Job
|
43
|
+
# ...
|
44
|
+
def done?
|
45
|
+
[:failure, :success, :partial_success].include?(@status)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
Now, say we need another kind of job: the `AdminJob`. It needs to have the same set of statuses (with the same behavior as `Job`'s status). We could certainly move the status related code into a `StatusHaving` module and mix it in to both `Job` and `AdminJob`, but there are some drawbacks here, chief among them that we'd have to add a good bit of coupling between the `Job`, `AdminJob` and the `StatusHaving` mix-in module. For example both classes and the mix-in would need to agree on the `@status` instance variable. I would argue at this point the idea of a `JobStatus` should be promoted to it's own class, maybe with a little bit of error checking:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
class JobStatus
|
54
|
+
NAMES = [:pending, :success, :failure]
|
55
|
+
|
56
|
+
def initialize(name)
|
57
|
+
unless NAMES.include?(name)
|
58
|
+
raise ArugmentError.new("Invalid status #{name.inspect}")
|
59
|
+
end
|
60
|
+
|
61
|
+
@name = name
|
62
|
+
end
|
63
|
+
end
|
64
|
+
```
|
65
|
+
|
66
|
+
and then in `Job`:
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
class Job
|
70
|
+
def initialize
|
71
|
+
@status = JobStatus.new(:pending)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
```
|
75
|
+
|
76
|
+
There are some advantages to this approach:
|
77
|
+
|
78
|
+
1. The list of all the possible statuses lives in *only one place*. I think it's way easier to look at the `JobStatus` class and see what the possible statuses are than it is to hunt through the `Job` class Looking for symbols assigned to `@status`.
|
79
|
+
2. It's now possible to interact with the list of all legal statuses programmatically (perhaps in an admin console that has a drop down for of all statuses so that they may be manually updated).
|
80
|
+
3. We can separate behavior (methods) that apply to the enumerated types from the classes that include one of these types (`Job` in our example). Although the status handling code in our version of `Job` was fairly simple, it often becomes clear that handling the status of a job is a very separate concern from the actual processing of a `Job`, and this implementing both in a single class becomes an [SRP][http://en.wikipedia.org/wiki/Single_responsibility_principle] violation.
|
81
|
+
4. By separating the `JobStatus` behavior from the `Job`, we're able to more easily respond to change in requirements around the `JobStatus` in the future. Perhaps at some point we'll want to transform `JobStatus` into a state machine where only certain transitions are allowed, or include code that audits the change of state, etc.
|
82
|
+
|
83
|
+
"But all did was explain enumerated types, the kind that are present all over the place in other languages." Yep. That is correct. The only downside of the approach shown is that you have to re-create very similar, one-off implementations of an enumerated type. The `EnumeratedType` gem is just a clean and simple Ruby implementation of a well known concept.
|
18
84
|
|
19
85
|
## Usage
|
20
86
|
|
21
|
-
|
87
|
+
Define an enumerated type:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
class JobStatus
|
91
|
+
include EnumeratedType
|
92
|
+
|
93
|
+
declare :pending
|
94
|
+
declare :success
|
95
|
+
declare :failure
|
96
|
+
end
|
97
|
+
```
|
98
|
+
|
99
|
+
Get an instance of an enumerated type:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
# Via constant...
|
103
|
+
@status = JobStatus::PENDING
|
104
|
+
|
105
|
+
# Or via symbol...
|
106
|
+
@status = JobStatus[:pending]
|
107
|
+
@status = JobStatus[:does_not_exist] #=> raises an ArgumentError
|
108
|
+
```
|
109
|
+
|
110
|
+
All instances have predicate methods defined automatically:
|
111
|
+
|
112
|
+
```ruby
|
113
|
+
@status = JobStatus::PENDING
|
114
|
+
@status.pending? # => true
|
115
|
+
@status.failure? # => false
|
116
|
+
```
|
117
|
+
|
118
|
+
Get the original symbol used to define the type:
|
119
|
+
|
120
|
+
```ruby
|
121
|
+
JobStatus::PENDING.name # => :pending
|
122
|
+
```
|
123
|
+
|
124
|
+
A class that includes `EnumeratedType` is just a regular Ruby class, so feel free to (and definitely do) add your own methods:
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
class JobStatus
|
128
|
+
#...
|
129
|
+
def done?
|
130
|
+
success? or failure?
|
131
|
+
end
|
132
|
+
end
|
133
|
+
```
|
134
|
+
|
135
|
+
The only exception exception to the "just a regular ruby class" rule is that the constructor (`JobStatus.new`) is privatized.
|
136
|
+
|
137
|
+
Classes that include enumerated types themselves become enumerable, so you can do things like:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
JobStatus.map(&:name).sort.each { |n| puts "JobStatus: #{n}" }
|
141
|
+
```
|
142
|
+
|
143
|
+
## Bonus Features
|
144
|
+
|
145
|
+
Create enumerated types with ultra-low ceremony:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
JobStatus = EnumeratedType.new(:pending, :success, :failure)
|
149
|
+
```
|
150
|
+
|
151
|
+
Add arbitrary attributes:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
class JobStatus
|
155
|
+
include EnumeratedType
|
156
|
+
|
157
|
+
declare :pending, :message => "Your Job is waiting to be processed"
|
158
|
+
declare :success, :message => "Your Job has completed"
|
159
|
+
declare :failure, :message => "Oops, it looks like there was a problem"
|
160
|
+
end
|
161
|
+
|
162
|
+
JobStatus::SUCCESS.message # => "Your job has completed"
|
163
|
+
```
|
164
|
+
|
165
|
+
## Development
|
22
166
|
|
23
|
-
|
167
|
+
To run the tests (assuming you have already run `gem install bundler`):
|
24
168
|
|
25
|
-
|
26
|
-
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
-
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
-
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
-
5. Create new Pull Request
|
169
|
+
bundle install && rake test
|
data/lib/enumerated_type.rb
CHANGED
@@ -14,13 +14,28 @@ module EnumeratedType
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
+
def inspect
|
18
|
+
"#<#{self.class.name}:#{name}>"
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
name.to_s
|
23
|
+
end
|
24
|
+
|
17
25
|
private
|
18
26
|
|
19
|
-
def initialize(name,
|
20
|
-
@name
|
21
|
-
|
27
|
+
def initialize(name, properties)
|
28
|
+
@name = name
|
29
|
+
properties.each { |k, v| send(:"#{k}=", v) }
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.new(*names)
|
33
|
+
names = names.first if names.first.kind_of?(Enumerable)
|
22
34
|
|
23
|
-
|
35
|
+
Class.new do
|
36
|
+
include EnumeratedType
|
37
|
+
names.each { |n| declare(n) }
|
38
|
+
end
|
24
39
|
end
|
25
40
|
|
26
41
|
module ClassMethods
|
@@ -28,20 +43,11 @@ module EnumeratedType
|
|
28
43
|
@all.each(&block)
|
29
44
|
end
|
30
45
|
|
31
|
-
def
|
32
|
-
each { |e| return e if e.value == value }
|
33
|
-
raise ArgumentError, "Unrecognized #{self.name} value #{value.inspect}'"
|
34
|
-
end
|
35
|
-
|
36
|
-
def by_name(name)
|
46
|
+
def [](name)
|
37
47
|
each { |e| return e if e.name == name }
|
38
48
|
raise ArgumentError, "Unrecognized #{self.name} name #{name.inspect}'"
|
39
49
|
end
|
40
50
|
|
41
|
-
def [](name)
|
42
|
-
by_name(name)
|
43
|
-
end
|
44
|
-
|
45
51
|
def recognized?(name)
|
46
52
|
map(&:name).include?(name)
|
47
53
|
end
|
@@ -49,22 +55,26 @@ module EnumeratedType
|
|
49
55
|
private
|
50
56
|
|
51
57
|
def declare(name, options = {})
|
52
|
-
value = options.delete(:value)
|
53
|
-
value ||= (map(&:value).max || 0) + 1
|
54
|
-
|
55
58
|
if map(&:name).include?(name)
|
56
59
|
raise(ArgumentError, "duplicate name #{name.inspect}")
|
57
60
|
end
|
58
61
|
|
59
|
-
if map(&:value).include?(value)
|
60
|
-
raise(ArgumentError, "duplicate :value #{value.inspect}")
|
61
|
-
end
|
62
|
-
|
63
62
|
define_method(:"#{name}?") do
|
64
63
|
self.name == name
|
65
64
|
end
|
66
65
|
|
67
|
-
|
66
|
+
options.keys.each do |property|
|
67
|
+
unless instance_methods.include?(:"#{property}")
|
68
|
+
attr_reader(:"#{property}")
|
69
|
+
end
|
70
|
+
|
71
|
+
unless instance_methods.include?(:"#{property}=")
|
72
|
+
attr_writer(:"#{property}")
|
73
|
+
private(:"#{property}=")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
enumerated = new(name, options)
|
68
78
|
|
69
79
|
@all << enumerated
|
70
80
|
const_set(name.to_s.upcase, enumerated)
|
@@ -9,10 +9,8 @@ describe EnumeratedType do
|
|
9
9
|
class Gender
|
10
10
|
include EnumeratedType
|
11
11
|
|
12
|
-
|
13
|
-
|
14
|
-
declare(:male, :planet => "mars")
|
15
|
-
declare(:female, :planet => "venus")
|
12
|
+
declare :male, :planet => "mars"
|
13
|
+
declare :female, :planet => "venus"
|
16
14
|
end
|
17
15
|
|
18
16
|
it "privatizes the constructor" do
|
@@ -37,56 +35,60 @@ describe EnumeratedType do
|
|
37
35
|
Gender.entries.map(&:female?).must_equal [false, true]
|
38
36
|
end
|
39
37
|
|
40
|
-
describe ".
|
41
|
-
it "
|
42
|
-
|
38
|
+
describe ".new" do
|
39
|
+
it "returns an anonymous class" do
|
40
|
+
EnumeratedType.new.instance_of?(Class).must_equal true
|
41
|
+
EnumeratedType.new.name.must_equal nil
|
43
42
|
end
|
44
43
|
|
45
|
-
it "
|
46
|
-
|
47
|
-
|
44
|
+
it "returns a class that includes EnumeratedType" do
|
45
|
+
gender = EnumeratedType.new
|
46
|
+
gender.ancestors.include?(EnumeratedType).must_equal true
|
48
47
|
end
|
49
48
|
|
50
|
-
it "
|
51
|
-
gender =
|
52
|
-
|
53
|
-
|
54
|
-
declare :female, :value => 101
|
55
|
-
end
|
49
|
+
it "declares the given names as types (provided as arguments)" do
|
50
|
+
gender = EnumeratedType.new(:male, :female)
|
51
|
+
gender.map(&:name).must_equal [:male, :female]
|
52
|
+
end
|
56
53
|
|
57
|
-
|
54
|
+
it "declares the given names as types (provides as array)" do
|
55
|
+
gender = EnumeratedType.new([:male, :female])
|
56
|
+
gender.map(&:name).must_equal [:male, :female]
|
58
57
|
end
|
59
58
|
|
60
|
-
it "
|
61
|
-
|
59
|
+
it "declares the given names as types (provides any enumerable)" do
|
60
|
+
gender = EnumeratedType.new(Set.new([:male, :female]))
|
61
|
+
gender.map(&:name).must_equal [:male, :female]
|
62
|
+
end
|
63
|
+
end
|
62
64
|
|
63
|
-
|
65
|
+
describe ".declare" do
|
66
|
+
it "is private" do
|
67
|
+
lambda { Gender.declare }.must_raise(NoMethodError, /private method `declare' called/)
|
64
68
|
end
|
65
69
|
|
66
|
-
it "
|
67
|
-
Gender.
|
70
|
+
it "requires the name to be unique" do
|
71
|
+
duplicate_name = Gender.first.name
|
72
|
+
lambda { Gender.send(:declare, duplicate_name) }.must_raise(ArgumentError, /duplicate name/)
|
68
73
|
end
|
69
|
-
end
|
70
74
|
|
71
|
-
|
72
|
-
|
73
|
-
gender = Gender.first
|
74
|
-
Gender.by_name(gender.name).must_equal gender
|
75
|
+
it "assigns properties and makes them accessible" do
|
76
|
+
Gender.map(&:planet).must_equal ["mars", "venus"]
|
75
77
|
end
|
76
78
|
|
77
|
-
it "
|
78
|
-
|
79
|
+
it "does not expose public setters for properties" do
|
80
|
+
Gender::MALE.respond_to?(:planet=).must_equal false
|
79
81
|
end
|
80
82
|
end
|
81
83
|
|
82
|
-
describe ".
|
83
|
-
it "returns the type with the given
|
84
|
+
describe ".[]" do
|
85
|
+
it "returns the type with the given name" do
|
84
86
|
gender = Gender.first
|
85
|
-
Gender
|
87
|
+
Gender[gender.name].must_equal gender
|
86
88
|
end
|
87
89
|
|
88
|
-
it "raises an error given an unrecognized
|
89
|
-
lambda { Gender
|
90
|
+
it "raises an error given an unrecognized name" do
|
91
|
+
lambda { Gender[:neuter] }.must_raise ArgumentError
|
90
92
|
end
|
91
93
|
end
|
92
94
|
|
@@ -99,4 +101,16 @@ describe EnumeratedType do
|
|
99
101
|
Gender.recognized?(:neuter).must_equal false
|
100
102
|
end
|
101
103
|
end
|
104
|
+
|
105
|
+
describe "#inspect" do
|
106
|
+
it "looks reasonable" do
|
107
|
+
Gender::FEMALE.inspect.must_equal "#<Gender:female>"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
describe "#to_s" do
|
112
|
+
it "is the name (as a string)" do
|
113
|
+
Gender::MALE.to_s.must_equal "male"
|
114
|
+
end
|
115
|
+
end
|
102
116
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: enumerated_type
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-
|
12
|
+
date: 2013-12-06 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|
@@ -90,7 +90,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
90
|
version: '0'
|
91
91
|
segments:
|
92
92
|
- 0
|
93
|
-
hash:
|
93
|
+
hash: 37053798746470730
|
94
94
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
95
95
|
none: false
|
96
96
|
requirements:
|
@@ -99,10 +99,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
99
99
|
version: '0'
|
100
100
|
segments:
|
101
101
|
- 0
|
102
|
-
hash:
|
102
|
+
hash: 37053798746470730
|
103
103
|
requirements: []
|
104
104
|
rubyforge_project:
|
105
|
-
rubygems_version: 1.8.
|
105
|
+
rubygems_version: 1.8.26
|
106
106
|
signing_key:
|
107
107
|
specification_version: 3
|
108
108
|
summary: Simple enumerated types
|