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 CHANGED
@@ -1,29 +1,169 @@
1
- # Enumeration
1
+ # EnumeratedType
2
2
 
3
- TODO: Write a gem description
3
+ Dead simple enumerated types for Ruby.
4
4
 
5
- ## Installation
5
+ ## Background
6
6
 
7
- Add this line to your application's Gemfile:
7
+ This gem implements the familiar notion of enumerated types in Ruby.
8
8
 
9
- gem 'enumerated_type'
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
- And then execute:
11
+ ```ruby
12
+ class Job
13
+ attr_reader :status
12
14
 
13
- $ bundle
15
+ def initialize
16
+ @status = :pending
17
+ end
14
18
 
15
- Or install it yourself as:
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
- $ gem install enumerated_type
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
- TODO: Write usage instructions here
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
- ## Contributing
167
+ To run the tests (assuming you have already run `gem install bundler`):
24
168
 
25
- 1. Fork it
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
@@ -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, value, options = {})
20
- @name = name
21
- @value = value
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
- options.each { |k, v| send(:"#{k}=", v) }
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 by_value(value)
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
- enumerated = new(name, value, options)
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)
@@ -1,3 +1,3 @@
1
1
  module EnumeratedType
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -9,10 +9,8 @@ describe EnumeratedType do
9
9
  class Gender
10
10
  include EnumeratedType
11
11
 
12
- attr_accessor :planet
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 ".declare" do
41
- it "is private" do
42
- lambda { Gender.declare }.must_raise(NoMethodError, /private method `declare' called/)
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 "requires the name to be unique" do
46
- duplicate_name = Gender.first.name
47
- lambda { Gender.send(:declare, duplicate_name) }.must_raise(ArgumentError, /duplicate name/)
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 "allows you to specify :value" do
51
- gender = Class.new do
52
- include EnumeratedType
53
- declare :male, :value => 100
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
- gender.map(&:value).must_equal [100, 101]
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 "requires :value to be unique" do
61
- duplicate_value = Gender.first.value
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
- lambda { Gender.send(:declare, :neuter, :value => duplicate_value ) }.must_raise(ArgumentError, /duplicate :value/)
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 "assigns extra attributes from .declare" do
67
- Gender.map(&:planet).must_equal ["mars", "venus"]
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
- describe ".by_name" do
72
- it "returns the type with the given name" do
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 "raises an error given an unrecognized name" do
78
- lambda { Gender.by_name(:neuter) }.must_raise(ArgumentError)
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 ".by_value" do
83
- it "returns the type with the given value" do
84
+ describe ".[]" do
85
+ it "returns the type with the given name" do
84
86
  gender = Gender.first
85
- Gender.by_value(gender.value).must_equal gender
87
+ Gender[gender.name].must_equal gender
86
88
  end
87
89
 
88
- it "raises an error given an unrecognized value" do
89
- lambda { Gender.by_value((Gender.map(&:value).max) + 1) }.must_raise(ArgumentError)
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.2
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-09-04 00:00:00.000000000 Z
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: 2023803896666917699
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: 2023803896666917699
102
+ hash: 37053798746470730
103
103
  requirements: []
104
104
  rubyforge_project:
105
- rubygems_version: 1.8.23
105
+ rubygems_version: 1.8.26
106
106
  signing_key:
107
107
  specification_version: 3
108
108
  summary: Simple enumerated types