Skip to content

Commit

Permalink
Make enums validatable without raising error (#49100)
Browse files Browse the repository at this point in the history
* Make enums validatable without raising error

* Trigger fail CI

Co-authored-by: Rafael Mendonça França <rafael@rubyonrails.org>
  • Loading branch information
mechnicov and rafaelfranca committed Sep 1, 2023
1 parent cdbc9b7 commit 7c65a4b
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 3 deletions.
20 changes: 20 additions & 0 deletions activerecord/CHANGELOG.md
Expand Up @@ -9,6 +9,26 @@

*Nikita Vasilevsky*

* Add validation option for `enum`

```ruby
class Contract < ApplicationRecord
enum :status, %w[in_progress completed], validate: true
end
Contract.new(status: "unknown").valid? # => false
Contract.new(status: nil).valid? # => false
Contract.new(status: "completed").valid? # => true

class Contract < ApplicationRecord
enum :status, %w[in_progress completed], validate: { allow_nil: true }
end
Contract.new(status: "unknown").valid? # => false
Contract.new(status: nil).valid? # => true
Contract.new(status: "completed").valid? # => true
```

*Edem Topuzov*

* Allow batching methods to use already loaded relation if available

Calling batch methods on already loaded relations will use the records previously loaded instead of retrieving
Expand Down
59 changes: 56 additions & 3 deletions activerecord/lib/active_record/enum.rb
Expand Up @@ -118,6 +118,50 @@ module ActiveRecord
# class Conversation < ActiveRecord::Base
# enum :status, [ :active, :archived ], instance_methods: false
# end
#
# If you want the enum value to be validated before saving, use the option +:validate+:
#
# class Conversation < ActiveRecord::Base
# enum :status, [ :active, :archived ], validate: true
# end
#
# conversation = Conversation.new
#
# conversation.status = :unknown
# conversation.valid? # => false
#
# conversation.status = nil
# conversation.valid? # => false
#
# conversation.status = :active
# conversation.valid? # => true
#
# It is also possible to pass additional validation options:
#
# class Conversation < ActiveRecord::Base
# enum :status, [ :active, :archived ], validate: { allow_nil: true }
# end
#
# conversation = Conversation.new
#
# conversation.status = :unknown
# conversation.valid? # => false
#
# conversation.status = nil
# conversation.valid? # => true
#
# conversation.status = :active
# conversation.valid? # => true
#
# Otherwise +ArgumentError+ will raise:
#
# class Conversation < ActiveRecord::Base
# enum :status, [ :active, :archived ]
# end
#
# conversation = Conversation.new
#
# conversation.status = :unknown # 'unknown' is not a valid status (ArgumentError)
module Enum
def self.extended(base) # :nodoc:
base.class_attribute(:defined_enums, instance_writer: false, default: {})
Expand All @@ -135,10 +179,11 @@ def load_schema! # :nodoc:
class EnumType < Type::Value # :nodoc:
delegate :type, to: :subtype

def initialize(name, mapping, subtype)
def initialize(name, mapping, subtype, raise_on_invalid_values: true)
@name = name
@mapping = mapping
@subtype = subtype
@_raise_on_invalid_values = raise_on_invalid_values
end

def cast(value)
Expand All @@ -164,6 +209,8 @@ def serializable?(value, &block)
end

def assert_valid_value(value)
return unless @_raise_on_invalid_values

unless value.blank? || mapping.has_key?(value) || mapping.has_value?(value)
raise ArgumentError, "'#{value}' is not a valid #{name}"
end
Expand Down Expand Up @@ -193,7 +240,7 @@ def inherited(base)
super
end

def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods: true, **options)
def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods: true, validate: false, **options)
assert_valid_enum_definition_values(values)
# statuses = { }
enum_values = ActiveSupport::HashWithIndifferentAccess.new
Expand All @@ -209,7 +256,7 @@ def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods

attribute(name, **options) do |subtype|
subtype = subtype.subtype if EnumType === subtype
EnumType.new(name, enum_values, subtype)
EnumType.new(name, enum_values, subtype, raise_on_invalid_values: !validate)
end

value_method_names = []
Expand Down Expand Up @@ -241,6 +288,12 @@ def _enum(name, values, prefix: nil, suffix: nil, scopes: true, instance_methods
end
end
detect_negative_enum_conditions!(value_method_names) if scopes

if validate
validate = {} unless Hash === validate
validates_inclusion_of name, in: enum_values.keys, **validate
end

enum_values.freeze
end

Expand Down
38 changes: 38 additions & 0 deletions activerecord/test/cases/enum_test.rb
Expand Up @@ -313,6 +313,44 @@ class EnumTest < ActiveRecord::TestCase
assert_equal "'unknown' is not a valid status", e.message
end

test "validation with 'validate: true' option" do
klass = Class.new(ActiveRecord::Base) do
def self.name; "Book"; end
enum :status, [:proposed, :written], validate: true
end

valid_book = klass.new(status: "proposed")
assert_predicate valid_book, :valid?

valid_book = klass.new(status: "written")
assert_predicate valid_book, :valid?

invalid_book = klass.new(status: nil)
assert_not_predicate invalid_book, :valid?

invalid_book = klass.new(status: "unknown")
assert_not_predicate invalid_book, :valid?
end

test "validation with 'validate: hash' option" do
klass = Class.new(ActiveRecord::Base) do
def self.name; "Book"; end
enum :status, [:proposed, :written], validate: { allow_nil: true }
end

valid_book = klass.new(status: "proposed")
assert_predicate valid_book, :valid?

valid_book = klass.new(status: "written")
assert_predicate valid_book, :valid?

valid_book = klass.new(status: nil)
assert_predicate valid_book, :valid?

invalid_book = klass.new(status: "unknown")
assert_not_predicate invalid_book, :valid?
end

test "NULL values from database should be casted to nil" do
Book.where(id: @book.id).update_all("status = NULL")
assert_nil @book.reload.status
Expand Down

0 comments on commit 7c65a4b

Please sign in to comment.