Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow fallback to default value when assigning nil #370

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/virtus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 19 additions & 1 deletion lib/virtus/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions lib/virtus/attribute/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions lib/virtus/attribute/use_default_on_nil.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 13 additions & 8 deletions lib/virtus/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions spec/integration/building_module_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ module Examples
config.nullify_blank = true
}

DefaultOnNilModule = Virtus.model { |config|
config.use_default_on_nil = true
}

class NoncoercedUser
include NoncoercingModule

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions spec/unit/virtus/attribute/class_methods/build_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' }

Expand Down
102 changes: 97 additions & 5 deletions spec/unit/virtus/attribute/coerce_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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