diff --git a/CHANGELOG.md b/CHANGELOG.md index c26a19e0..a2b77b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Any violations of this scheme are considered to be bugs. * [#523](https://github.com/hashie/hashie/pull/523): Added TOC, ensure a keep-a-changelog formatted CHANGELOG - [@dblock](https://github.com/dblock). * [#522](https://github.com/hashie/hashie/pull/522): Added eierlegende Wollmilchsau mascot graphic - [@carolineartz](https://github.com/carolineartz). +* [#530](https://github.com/hashie/hashie/pull/530): Added Hashie::Extensions::Dash::PredefinedValues - [@caalberts](https://github.com/caalberts). * Your contribution here. ### Changed diff --git a/README.md b/README.md index 4280e7b5..4cf02140 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - [PropertyTranslation](#propertytranslation) - [Mash and Rails 4 Strong Parameters](#mash-and-rails-4-strong-parameters) - [Coercion](#coercion-1) + - [PredefinedValues](#predefinedvalues) - [Trash](#trash) - [Clash](#clash) - [Rash](#rash) @@ -968,6 +969,20 @@ class UserHash < Hashie::Dash end ``` +### PredefinedValues + +The `Hashie::Extensions::Dash::PredefinedValues` mixin extends a Dash with +the ability to accept predefined values on a property. + +```ruby +class UserHash < Hashie::Dash + include Hashie::Extensions::PredefinedValues + + property :gender, values: %i[male female prefer_not_to_say] + property :age, values: (0..150) +end +``` + ## Trash A Trash is a Dash that allows you to translate keys on initialization. It mixes diff --git a/lib/hashie.rb b/lib/hashie.rb index 7f88ed4a..8f526c44 100644 --- a/lib/hashie.rb +++ b/lib/hashie.rb @@ -41,6 +41,7 @@ module Dash autoload :IndifferentAccess, 'hashie/extensions/dash/indifferent_access' autoload :PropertyTranslation, 'hashie/extensions/dash/property_translation' autoload :Coercion, 'hashie/extensions/dash/coercion' + autoload :PredefinedValues, 'hashie/extensions/dash/predefined_values' end module Mash diff --git a/lib/hashie/extensions/dash/predefined_values.rb b/lib/hashie/extensions/dash/predefined_values.rb new file mode 100644 index 00000000..2a0b7ca7 --- /dev/null +++ b/lib/hashie/extensions/dash/predefined_values.rb @@ -0,0 +1,88 @@ +module Hashie + module Extensions + module Dash + # Extends a Dash with the ability to accept only predefined values on a property. + # + # == Example + # + # class PersonHash < Hashie::Dash + # include Hashie::Extensions::Dash::PredefinedValues + # + # property :gender, values: [:male, :female, :prefer_not_to_say] + # property :age, values: (0..150) # a Range + # end + # + # person = PersonHash.new(gender: :male, age: -1) + # # => ArgumentError: The value '-1' is not accepted for property 'age' + module PredefinedValues + def self.included(base) + base.instance_variable_set(:@values_for_properties, {}) + base.extend(ClassMethods) + base.include(InstanceMethods) + end + + module ClassMethods + attr_reader :values_for_properties + + def inherited(klass) + super + klass.instance_variable_set(:@values_for_properties, values_for_properties.dup) + end + + def property(property_name, options = {}) + super + + return unless (predefined_values = options[:values]) + + assert_predefined_values!(predefined_values) + set_predefined_values(property_name, predefined_values) + end + + private + + def assert_predefined_values!(predefined_values) + return if supported_type?(predefined_values) + + raise ArgumentError, %(`values` accepts an Array or a Range.) + end + + def supported_type?(predefined_values) + [::Array, ::Range].any? { |klass| predefined_values.is_a?(klass) } + end + + def set_predefined_values(property_name, predefined_values) + @values_for_properties[property_name] = predefined_values + end + end + + module InstanceMethods + def initialize(*) + super + + assert_property_values! + end + + private + + def assert_property_values! + self.class.values_for_properties.each_key do |property| + value = send(property) + + if value && !values_for_properties(property).include?(value) + fail_property_value_error!(property) + end + end + end + + def fail_property_value_error!(property) + raise ArgumentError, "Invalid value for property '#{property}'" + end + + def values_for_properties(property) + self.class.values_for_properties[property] + end + end + end + end + end +end diff --git a/spec/hashie/extensions/dash/predefined_values_spec.rb b/spec/hashie/extensions/dash/predefined_values_spec.rb new file mode 100644 index 00000000..a84fe809 --- /dev/null +++ b/spec/hashie/extensions/dash/predefined_values_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Hashie::Extensions::Dash::PredefinedValues do + let(:extended_dash) do + Class.new(Hashie::Dash) do + include Hashie::Extensions::Dash::PredefinedValues + + property :gender, values: %i[male female prefer_not_to_say] + property :age, values: (0..150) + end + end + + it 'allows value within the predefined list' do + valid_dash = extended_dash.new(gender: :male) + expect(valid_dash.gender).to eq(:male) + end + + it 'rejects value outside the predefined list' do + expect { extended_dash.new(gender: :unicorn) } + .to raise_error(ArgumentError, %(Invalid value for property 'gender')) + end + + it 'accepts a range for predefined list' do + expect { extended_dash.new(age: -1) } + .to raise_error(ArgumentError, %(Invalid value for property 'age')) + end + + it 'allows property to be nil' do + expect { extended_dash.new } + .not_to raise_error + end + + it 'rejects non array or range for predefined list' do + expect do + class DashWithUnsupportedValueType < Hashie::Dash + include Hashie::Extensions::Dash::PredefinedValues + + property :name, values: -> { :foo } + end + end.to raise_error(ArgumentError, %(`values` accepts an Array or a Range.)) + end + + let(:subclass) do + Class.new(extended_dash) do + property :language, values: %i[ruby javascript] + end + end + + it 'passes property predefined list to subclasses' do + expect { subclass.new(gender: :unicorn) } + .to raise_error(ArgumentError, %(Invalid value for property 'gender')) + end + + it 'allows subclass to define predefined list' do + expect { subclass.new(language: :ruby) } + .not_to raise_error + end +end