From e11b3107527d3c01c132ca078a6e360163980d02 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Fri, 14 Aug 2026 21:26:12 -0400 Subject: [PATCH] Inherit keys, key?, value?, key, value, to_h, parse and each from superclass Follow-up to #49/#58. values already merged enums from a superclass chain, but keys, key?, value?, key, value, to_h, parse and each only looked at a class' own enums, silently ignoring any enums defined in a superclass. Introduces _enum_hash/_enums_by_value that merge a class' own enums with those of its superclass chain (subclass definitions take precedence over the same key/value defined in a superclass), and uses them for all read methods. define/duplicate validation continues to use the class' own enums only (_own_enum_hash/_own_enums_by_value), so a subclass may still redefine a key or value already used by a superclass. Documents the change in README.md and UPGRADING.md, and adds tests covering a subclass redefining a parent class' key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 22 +++++++- UPGRADING.md | 39 ++++++++++++++ lib/ruby-enum/enum.rb | 61 ++++++++++++++------- spec/ruby-enum/enum_spec.rb | 102 ++++++++++++++++++++++++++++++++---- 5 files changed, 196 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a33a036..6c46e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ### 1.2.0 (Next) * [#49](https://github.com/dblock/ruby-enum/issues/49): Fixed `NoMethodError` on `keys`, `values`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` when a subclass defines no enums of its own - [@dblock](https://github.com/dblock). +* [#49](https://github.com/dblock/ruby-enum/issues/49): `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` now include enums defined in a superclass, matching the existing behavior of `values` - [@dblock](https://github.com/dblock). * Your contribution here. ### 1.1.0 (2026/6/20) diff --git a/README.md b/README.md index 38aed41..0c9563a 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ The `DuplicateValueError` exception is raised to be consistent with the unique k ### Inheritance -When inheriting from a `Ruby::Enum` class, all defined enums in the parent class will be accessible in sub classes as well. Sub classes can also provide extra enums, as usual. +When inheriting from a `Ruby::Enum` class, all defined enums in the parent class will be accessible in subclasses as well. Subclasses can also provide extra enums, as usual. ``` ruby class OrderState @@ -262,6 +262,26 @@ OrderState.values # ['CREATED', 'PAID'] ShippedOrderState.values # ['CREATED', 'PAID', 'PREPARED', SHIPPED'] ``` +All other enumerating and hashing methods (`keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each`) also consider enums defined anywhere in the class hierarchy. + +``` ruby +ShippedOrderState.keys # [:CREATED, :PAID, :PREPARED, :SHIPPED] +ShippedOrderState.key?(:CREATED) # true +ShippedOrderState.value(:CREATED) # 'CREATED' +``` + +A subclass may redefine a key or value already used by a parent class without raising `DuplicateKeyError` or `DuplicateValueError`; its own definition takes precedence. + +``` ruby +class ShippedOrderState < OrderState + define :CREATED, 'RECREATED' # does not raise, overrides the parent class' definition +end + +ShippedOrderState::CREATED # 'RECREATED' +ShippedOrderState.value(:CREATED) # 'RECREATED' +OrderState.value(:CREATED) # 'CREATED', unaffected +``` + ### Exhaustive case matcher If you want to make sure that you cover all cases in a case stament, you can use the exhaustive case matcher: `Ruby::Enum::Case`. It will raise an error if a case/enum value is not handled, or if a value is specified that's not part of the enum. This is inspired by the [Rust Pattern Syntax](https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html). If multiple cases match, all matches are being executed. The return value is the value from the matched case, or an array of return values if multiple cases matched. diff --git a/UPGRADING.md b/UPGRADING.md index 1a25311..9fa3247 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,5 +1,44 @@ # Upgrading Ruby::Enum +## Upgrading to >= 1.2.0 + +### Inheritance & `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` + +This only applies to classes that inherit from another which is a `Ruby::Enum`. + +Prior to version `1.2.0`, only `values` enumerated enums defined in the entire class hierarchy; `keys`, `key?`, `value?`, `key`, `value`, `to_h`, `parse` and `each` only considered enums defined directly on the class, silently ignoring anything defined in a superclass. + +As of version `1.2.0`, these methods behave consistently with `values` and also enumerate/consider enums defined anywhere in the class hierarchy, ancestors first. A subclass may still redefine a key or value already used by a superclass; its own definition takes precedence. + +``` ruby +class PrimaryColors + include Ruby::Enum + + define :RED, 'RED' + define :GREEN, 'GREEN' +end + +class RainbowColors < PrimaryColors + define :ORANGE, 'ORANGE' +end +``` + +`gem 'ruby-enum', '< 1.2.0'` + +``` ruby +RainbowColors.keys # => [:ORANGE] +RainbowColors.key?(:RED) # => false +``` + +`gem 'ruby-enum', '>= 1.2.0'` + +``` ruby +RainbowColors.keys # => [:RED, :GREEN, :ORANGE] +RainbowColors.key?(:RED) # => true +``` + +See [#49](https://github.com/dblock/ruby-enum/issues/49) for more information. + ## Upgrading to >= 0.9.0 ### Inheritance & `Ruby::Enum.values` diff --git a/lib/ruby-enum/enum.rb b/lib/ruby-enum/enum.rb index 13ef828..66cac1c 100644 --- a/lib/ruby-enum/enum.rb +++ b/lib/ruby-enum/enum.rb @@ -20,8 +20,8 @@ def self.included(base) base.private_class_method(:new) - base.instance_variable_set(:@_enum_hash, {}) - base.instance_variable_set(:@_enums_by_value, {}) + base.instance_variable_set(:@_own_enum_hash, {}) + base.instance_variable_set(:@_own_enums_by_value, {}) end module ClassMethods @@ -45,15 +45,15 @@ def define(key, value = key) def store_new_instance(key, value) new_instance = new(key, value) - _enum_hash[key] = new_instance - _enums_by_value[value] = new_instance + _own_enum_hash[key] = new_instance + _own_enums_by_value[value] = new_instance end def const_missing(key) raise Ruby::Enum::Errors::UninitializedConstantError, name: name, key: key end - # Iterate over all enumerated values. + # Iterate over all enumerated values, including those defined in a superclass. # Required for Enumerable mixin def each(&block) _enum_hash.each(&block) @@ -74,7 +74,7 @@ def parse(k) nil end - # Whether the specified key exists in this enum. + # Whether the specified key exists in this enum, including those defined in a superclass. # # === Parameters # [k] The string key to check. @@ -84,7 +84,7 @@ def key?(k) _enum_hash.key?(k) end - # Gets the string value for the specified key. + # Gets the string value for the specified key, including those defined in a superclass. # # === Parameters # [k] The key symbol to get the value for. @@ -95,7 +95,7 @@ def value(k) enum&.value end - # Whether the specified value exists in this enum. + # Whether the specified value exists in this enum, including those defined in a superclass. # # === Parameters # [k] The string value to check. @@ -105,7 +105,7 @@ def value?(v) _enums_by_value.key?(v) end - # Gets the key symbol for the specified value. + # Gets the key symbol for the specified value, including those defined in a superclass. # # === Parameters # [v] The string value to parse. @@ -116,14 +116,14 @@ def key(v) enum&.key end - # Returns all enum keys. + # Returns all enum keys, including those defined in a superclass. def keys _enum_hash.values.map(&:key) end # Returns all enum values. def values - result = _enum_hash.values.map(&:value) + result = _own_enum_hash.values.map(&:value) if superclass < Ruby::Enum superclass.values + result @@ -132,7 +132,7 @@ def values end end - # Iterate over all enumerated values. + # Iterate over all enumerated values, including those defined in a superclass. # Required for Enumerable mixin def each_value(&_block) _enum_hash.each_value do |v| @@ -140,7 +140,7 @@ def each_value(&_block) end end - # Iterate over all enumerated keys. + # Iterate over all enumerated keys, including those defined in a superclass. # Required for Enumerable mixin def each_key(&_block) _enum_hash.each_value do |v| @@ -148,6 +148,7 @@ def each_key(&_block) end end + # Returns a hash of key:values, including those defined in a superclass. def to_h _enum_hash.transform_values(&:value) end @@ -157,15 +158,37 @@ def to_h # Returns this class' own enum hash, defaulting to an empty hash. # # A subclass that does not `define` any of its own enums does not have - # its `@_enum_hash` instance variable set, since it's only initialized + # its `@_own_enum_hash` instance variable set, since it's only initialized # in `define` and in the `included` hook. - def _enum_hash - @_enum_hash ||= {} + def _own_enum_hash + @_own_enum_hash ||= {} end # Returns this class' own enums-by-value hash, defaulting to an empty hash. + def _own_enums_by_value + @_own_enums_by_value ||= {} + end + + # Returns the enum hash for this class merged with all of its superclasses, + # with keys defined in this class taking precedence over those inherited + # from a superclass. + def _enum_hash + if superclass < Ruby::Enum + superclass.send(:_enum_hash).merge(_own_enum_hash) + else + _own_enum_hash + end + end + + # Returns the enums-by-value hash for this class merged with all of its + # superclasses, with values defined in this class taking precedence over + # those inherited from a superclass. def _enums_by_value - @_enums_by_value ||= {} + if superclass < Ruby::Enum + superclass.send(:_enums_by_value).merge(_own_enums_by_value) + else + _own_enums_by_value + end end def upper?(s) @@ -173,13 +196,13 @@ def upper?(s) end def validate_key!(key) - return unless _enum_hash.key?(key) + return unless _own_enum_hash.key?(key) raise Ruby::Enum::Errors::DuplicateKeyError, name: name, key: key end def validate_value!(value) - return unless _enums_by_value.key?(value) + return unless _own_enums_by_value.key?(value) raise Ruby::Enum::Errors::DuplicateValueError, name: name, value: value end diff --git a/spec/ruby-enum/enum_spec.rb b/spec/ruby-enum/enum_spec.rb index 5262ff4..dc64fdd 100644 --- a/spec/ruby-enum/enum_spec.rb +++ b/spec/ruby-enum/enum_spec.rb @@ -27,6 +27,10 @@ class OtherSecondSubclass < FirstSubclass class SubclassWithNoOwnDefines < Colors end + class SubclassRedefiningParentKey < Colors + define :RED, 'crimson' + end + it 'returns an enum value' do expect(Colors::RED).to eq 'red' expect(Colors::GREEN).to eq 'green' @@ -66,6 +70,13 @@ class SubclassWithNoOwnDefines < Colors expect(enum_keys).to eq %i[RED GREEN] expect(enum_values).to eq %w[red green] end + + context 'when a subclass defines its own enums' do + it 'iterates over the enums defined in the parent class too' do + keys = FirstSubclass.map { |key, _enum| key } + expect(keys).to eq %i[RED GREEN ORANGE] + end + end end describe '#map' do @@ -95,6 +106,12 @@ class SubclassWithNoOwnDefines < Colors it 'returns nil for an invalid value' do expect(Colors.parse('invalid')).to be_nil end + + context 'when a subclass defines its own enums' do + it 'parses a value defined in the parent class' do + expect(FirstSubclass.parse('red')).to eq('red') + end + end end describe '#key?' do @@ -113,6 +130,12 @@ class SubclassWithNoOwnDefines < Colors it 'returns false for invalid keys' do expect(Colors.key?(:NOT_A_KEY)).to be(false) end + + context 'when a subclass defines its own enums' do + it 'returns true for keys inherited from the parent class' do + expect(FirstSubclass.key?(:RED)).to be(true) + end + end end describe '#value' do @@ -125,6 +148,12 @@ class SubclassWithNoOwnDefines < Colors it 'returns nil for an invalid key' do expect(Colors.value(:NOT_A_KEY)).to be_nil end + + context 'when a subclass defines its own enums' do + it 'returns the value for a key inherited from the parent class' do + expect(FirstSubclass.value(:RED)).to eq('red') + end + end end describe '#value?' do @@ -143,6 +172,12 @@ class SubclassWithNoOwnDefines < Colors it 'returns false for invalid values' do expect(Colors.value?('I am not a value')).to be(false) end + + context 'when a subclass defines its own enums' do + it 'returns true for values inherited from the parent class' do + expect(FirstSubclass.value?('red')).to be(true) + end + end end describe '#key' do @@ -155,12 +190,24 @@ class SubclassWithNoOwnDefines < Colors it 'returns nil for an invalid value' do expect(Colors.key('invalid')).to be_nil end + + context 'when a subclass defines its own enums' do + it 'returns the key for a value inherited from the parent class' do + expect(FirstSubclass.key('red')).to eq(:RED) + end + end end describe '#keys' do it 'returns keys' do expect(Colors.keys).to eq(%i[RED GREEN]) end + + context 'when a subclass defines its own enums' do + it 'includes keys from the parent class' do + expect(FirstSubclass.keys).to eq(%i[RED GREEN ORANGE]) + end + end end describe '#values' do @@ -197,6 +244,12 @@ class SubclassWithNoOwnDefines < Colors it 'returns a hash of key:values' do expect(Colors.to_h).to eq(RED: 'red', GREEN: 'green') end + + context 'when a subclass defines its own enums' do + it 'includes key:values from the parent class' do + expect(FirstSubclass.to_h).to eq(RED: 'red', GREEN: 'green', ORANGE: 'orange') + end + end end context 'when a duplicate key is used' do @@ -354,15 +407,46 @@ class EmptyEnums expect(SubclassWithNoOwnDefines.values).to eq(%w[red green]) end - it 'does not raise when calling keys, key?, value?, key, value, to_h, parse or each' do - expect(SubclassWithNoOwnDefines.keys).to eq([]) - expect(SubclassWithNoOwnDefines.key?(:RED)).to be false - expect(SubclassWithNoOwnDefines.value?('red')).to be false - expect(SubclassWithNoOwnDefines.key('red')).to be_nil - expect(SubclassWithNoOwnDefines.value(:RED)).to be_nil - expect(SubclassWithNoOwnDefines.to_h).to eq({}) - expect(SubclassWithNoOwnDefines.parse('red')).to be_nil - expect(SubclassWithNoOwnDefines.each.to_a).to eq([]) + it 'inherits the parent class enums for keys, key?, value?, key, value, to_h, parse and each' do + expect(SubclassWithNoOwnDefines.keys).to eq(%i[RED GREEN]) + expect(SubclassWithNoOwnDefines.key?(:RED)).to be true + expect(SubclassWithNoOwnDefines.value?('red')).to be true + expect(SubclassWithNoOwnDefines.key('red')).to eq :RED + expect(SubclassWithNoOwnDefines.value(:RED)).to eq 'red' + expect(SubclassWithNoOwnDefines.to_h).to eq(RED: 'red', GREEN: 'green') + expect(SubclassWithNoOwnDefines.parse('red')).to eq 'red' + expect(SubclassWithNoOwnDefines.each.map { |key, _enum| key }).to eq(%i[RED GREEN]) + end + end + + context 'when a subclass redefines a key already used by a parent class' do + it 'does not raise a duplicate key error' do + expect(SubclassRedefiningParentKey::RED).to eq 'crimson' + end + + it "the subclass' own value takes precedence" do + expect(SubclassRedefiningParentKey.value(:RED)).to eq 'crimson' + end + + it "the subclass' own value is used in to_h" do + expect(SubclassRedefiningParentKey.to_h).to eq(RED: 'crimson', GREEN: 'green') + end + + it 'includes both the parent and its own redefined value in values' do + expect(SubclassRedefiningParentKey.values).to eq(%w[red green crimson]) + end + + it "the parent class' own value is unaffected" do + expect(Colors::RED).to eq 'red' + expect(Colors.value(:RED)).to eq 'red' + end + + it 'still allows the parent class to raise a duplicate key error for its own re-definitions' do + expect do + Colors.class_eval do + define :RED, 'red' + end + end.to raise_error Ruby::Enum::Errors::DuplicateKeyError, /RED/ end end end