diff --git a/README.md b/README.md index 1dbc501b..309ec73d 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,19 @@ end User.new(:birthday => "").birthday # => nil ``` +## Use Default On Nil Mode + +If you have attributes with `:default` set, you can use the `:use_default_on_nil` option to fall back to the default value whenever the attribute is set to `nil`. + +```ruby +class Page + include Virtus.model(:use_default_on_nil => true) + + attribute :views, Integer, :default => 0 +end + +Page(:views => nil).views # => 0 +``` ## Building modules with custom configuration diff --git a/lib/virtus.rb b/lib/virtus.rb index 1b83ab0b..bc6a06c5 100644 --- a/lib/virtus.rb +++ b/lib/virtus.rb @@ -280,6 +280,7 @@ def self.warn(msg) require 'virtus/attribute/strict' require 'virtus/attribute/lazy_default' require 'virtus/attribute/nullify_blank' +require 'virtus/attribute/use_default_on_nil' require 'virtus/attribute/boolean' require 'virtus/attribute/collection' diff --git a/lib/virtus/attribute.rb b/lib/virtus/attribute.rb index 0654d4d1..6fdbcfc7 100644 --- a/lib/virtus/attribute.rb +++ b/lib/virtus/attribute.rb @@ -20,13 +20,14 @@ class Attribute include Equalizer.new(inspect) << :type << :options - accept_options :primitive, :accessor, :default, :lazy, :strict, :required, :finalize, :nullify_blank + accept_options :primitive, :accessor, :default, :lazy, :strict, :required, :finalize, :nullify_blank, :use_default_on_nil strict false required true accessor :public finalize true nullify_blank false + use_default_on_nil false # @see Virtus.coerce # @@ -194,6 +195,23 @@ def nullify_blank? kind_of?(NullifyBlank) end + # Return if the attribute is to use the default value when set to nil + # + # @example + # + # attr = Virtus::Attribute.build(String, :use_default_on_nil => true) + # attr.use_default_on_nil? # => true + # + # attr = Virtus::Attribute.build(String, :use_default_on_nil => false) + # attr.use_default_on_nil? # => false + # + # @return [Boolean] + # + # @api public + def use_default_on_nil? + kind_of?(UseDefaultOnNil) + end + # Return if the attribute is accepts nil values as valid coercion output # # @example diff --git a/lib/virtus/attribute/builder.rb b/lib/virtus/attribute/builder.rb index 9ac6faa5..e3ab4cab 100644 --- a/lib/virtus/attribute/builder.rb +++ b/lib/virtus/attribute/builder.rb @@ -160,11 +160,12 @@ def initialize_coercer def initialize_attribute @attribute = klass.new(type, options) - @attribute.extend(Accessor) if options[:name] - @attribute.extend(Coercible) if options[:coerce] - @attribute.extend(NullifyBlank) if options[:nullify_blank] - @attribute.extend(Strict) if options[:strict] - @attribute.extend(LazyDefault) if options[:lazy] + @attribute.extend(Accessor) if options[:name] + @attribute.extend(Coercible) if options[:coerce] + @attribute.extend(NullifyBlank) if options[:nullify_blank] + @attribute.extend(UseDefaultOnNil) if options[:use_default_on_nil] + @attribute.extend(Strict) if options[:strict] + @attribute.extend(LazyDefault) if options[:lazy] @attribute.finalize if options[:finalize] end diff --git a/lib/virtus/attribute/use_default_on_nil.rb b/lib/virtus/attribute/use_default_on_nil.rb new file mode 100644 index 00000000..173119d9 --- /dev/null +++ b/lib/virtus/attribute/use_default_on_nil.rb @@ -0,0 +1,24 @@ +module Virtus + class Attribute + + # Attribute extension which falls back nil attributes to default value + # + module UseDefaultOnNil + + # @see [Attribute#coerce] + # + # @api public + def coerce(input) + output = super + + if !value_coerced?(output) && input.nil? + super(default_value.value) + else + output + end + end + + end # UseDefaultOnNil + + end # Attribute +end # Virtus diff --git a/lib/virtus/configuration.rb b/lib/virtus/configuration.rb index 7e91b300..77f551eb 100644 --- a/lib/virtus/configuration.rb +++ b/lib/virtus/configuration.rb @@ -15,6 +15,9 @@ class Configuration # Access the nullify_blank setting for this instance attr_accessor :nullify_blank + # Access the use_default_on_nil setting for this instance + attr_accessor :use_default_on_nil + # Access the required setting for this instance attr_accessor :required @@ -30,14 +33,15 @@ class Configuration # # @api private def initialize(options={}) - @finalize = options.fetch(:finalize, true) - @coerce = options.fetch(:coerce, true) - @strict = options.fetch(:strict, false) - @nullify_blank = options.fetch(:nullify_blank, false) - @required = options.fetch(:required, true) - @constructor = options.fetch(:constructor, true) - @mass_assignment = options.fetch(:mass_assignment, true) - @coercer = Coercible::Coercer.new + @finalize = options.fetch(:finalize, true) + @coerce = options.fetch(:coerce, true) + @strict = options.fetch(:strict, false) + @nullify_blank = options.fetch(:nullify_blank, false) + @use_default_on_nil = options.fetch(:use_default_on_nil, false) + @required = options.fetch(:required, true) + @constructor = options.fetch(:constructor, true) + @mass_assignment = options.fetch(:mass_assignment, true) + @coercer = Coercible::Coercer.new yield self if block_given? end @@ -64,6 +68,7 @@ def to_h :finalize => finalize, :strict => strict, :nullify_blank => nullify_blank, + :use_default_on_nil => use_default_on_nil, :required => required, :configured_coercer => coercer }.freeze end diff --git a/spec/integration/building_module_spec.rb b/spec/integration/building_module_spec.rb index ed492a21..362b473f 100644 --- a/spec/integration/building_module_spec.rb +++ b/spec/integration/building_module_spec.rb @@ -23,6 +23,10 @@ module Examples config.nullify_blank = true } + DefaultOnNilModule = Virtus.model { |config| + config.use_default_on_nil = true + } + class NoncoercedUser include NoncoercingModule @@ -50,6 +54,13 @@ class BlankModel attribute :stuff, Hash attribute :happy, Boolean, :nullify_blank => false end + + class DefaultOnNilModel + include DefaultOnNilModule + + attribute :name, String, :default => 'foo' + attribute :happy, Boolean, :default => true, :use_default_on_nil => false + end end end @@ -87,4 +98,14 @@ class BlankModel expect(model.happy).to eql('foo') end + + specify 'including a custom module with use default on nil enabled' do + model = Examples::DefaultOnNilModel.new + + model.name = nil + expect(model.name).to eql('foo') + + model.happy = nil + expect(model.happy).to be_nil + end end diff --git a/spec/unit/virtus/attribute/class_methods/build_spec.rb b/spec/unit/virtus/attribute/class_methods/build_spec.rb index b9427ed2..56f02336 100644 --- a/spec/unit/virtus/attribute/class_methods/build_spec.rb +++ b/spec/unit/virtus/attribute/class_methods/build_spec.rb @@ -86,6 +86,14 @@ it { is_expected.to be_nullify_blank } end + context 'when options specify use default on nil mode' do + let(:options) { { :use_default_on_nil => true } } + + it_behaves_like 'a valid attribute instance' + + it { is_expected.to be_use_default_on_nil } + end + context 'when type is a string' do let(:type) { 'Integer' } diff --git a/spec/unit/virtus/attribute/coerce_spec.rb b/spec/unit/virtus/attribute/coerce_spec.rb index a9d11906..a6808a82 100644 --- a/spec/unit/virtus/attribute/coerce_spec.rb +++ b/spec/unit/virtus/attribute/coerce_spec.rb @@ -7,13 +7,20 @@ let(:object) { described_class.build(String, - :coercer => coercer, :strict => strict, :required => required, :nullify_blank => nullify_blank) + :coercer => coercer, + :strict => strict, + :required => required, + :default => default, + :nullify_blank => nullify_blank, + :use_default_on_nil => use_default_on_nil) } - let(:required) { true } - let(:nullify_blank) { false } - let(:input) { 1 } - let(:output) { '1' } + let(:required) { true } + let(:default) { nil } + let(:nullify_blank) { false } + let(:use_default_on_nil) { false } + let(:input) { 1 } + let(:output) { '1' } context 'when strict mode is turned off' do let(:strict) { false } @@ -126,4 +133,89 @@ end end end + + context 'when use_default_on_nil is turned on' do + let(:use_default_on_nil) { true } + let(:strict) { false } + + context 'when the input is nil' do + let(:input) { nil } + let(:output) { 'coerced' } + + context 'when a default is set' do + let(:default) { 'foo' } + + it 'returns the default value if input was not coerced' do + mock(coercer).call(input) { input } + mock(coercer).success?(String, input) { false } + mock(coercer).call(default) { default } + + expect(subject).to be(default) + + expect(coercer).to have_received.call(input) + expect(coercer).to have_received.success?(String, input) + expect(coercer).to have_received.call(default) + end + + it 'returns the output if input was coerced' do + mock(coercer).call(input) { output } + mock(coercer).success?(String, output) { true } + + expect(subject).to be(output) + + expect(coercer).to have_received.call(input) + expect(coercer).to have_received.success?(String, output) + end + end + + context 'when a default is not set' do + it 'returns nil if input was not coerced' do + mock(coercer).call(input) { input } + mock(coercer).success?(String, input) { false } + + expect(subject).to be_nil + + expect(coercer).to have_received.call(input) + expect(coercer).to have_received.success?(String, input) + end + end + end + + context 'when the input is not nil' do + let(:input) { 1 } + + it 'does not fallback to nil even if input was not coerced' do + mock(coercer).call(input) { input } + mock(coercer).success?(String, input) { false } + + expect(subject).to be(input) + + expect(coercer).to have_received.call(input) + expect(coercer).to have_received.success?(String, input) + end + end + end + + context 'when both use_default_on_nil and strict are turned on' do + let(:use_default_on_nil) { true } + let(:strict) { false } + + context 'when the input is nil and a default is set' do + let(:input) { nil } + let(:default) { 'foo' } + + it 'does not raise a coercion error' do + mock(coercer).call(input) { input } + mock(coercer).success?(String, input) { false } + mock(coercer).call(default) { default } + + expect { subject }.not_to raise_error + expect(subject).to be(default) + + expect(coercer).to have_received.call(input) + expect(coercer).to have_received.success?(String, input) + expect(coercer).to have_received.call(default) + end + end + end end