Skip to content
Merged
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
77 changes: 77 additions & 0 deletions lib/validated_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ class Base
class Boolean
end

# A private class definition for union types.
# Stores multiple allowed types for validation.
# Created via ValidatedObject::Base.union(*types)
#
# @example
# validates :id, type: union(String, Integer)
# validates :data, type: union(Hash, [Hash])
class Union
attr_reader :types

def initialize(*types)
@types = types
end
end

# Instantiate and validate a new object.
# @example
# maru = Dog.new(birthday: Date.today, name: 'Maru')
Expand Down Expand Up @@ -99,6 +114,14 @@ def validate_each(record, attribute, value)
validation_options = options
expected_class = validation_options[:with]

# Support union types
if expected_class.is_a?(Union)
return if validate_union_type(record, attribute, value, expected_class, validation_options)

save_union_error(record, attribute, value, expected_class, validation_options)
return
end

# Support type: Array, element_type: ElementType
if expected_class == Array && validation_options[:element_type]
return save_error(record, attribute, value, validation_options) unless value.is_a?(Array)
Expand Down Expand Up @@ -134,6 +157,50 @@ def save_error(record, attribute, value, validation_options)
record.errors.add attribute,
validation_options[:message] || "is a #{value.class}, not a #{validation_options[:with]}"
end

def validate_union_type(_record, _attribute, value, union, _validation_options)
union.types.any? do |type_spec|
if type_spec.is_a?(Array) && type_spec.length == 1
# Handle [ElementType] syntax within union
validate_array_element_type(value, type_spec[0])
elsif type_spec.is_a?(Class) || type_spec == Boolean
# Handle class types (String, Integer, etc.) and pseudo-boolean
pseudo_boolean?(type_spec, value) || expected_class?(type_spec, value)
else
# Handle literal values (symbols, strings, numbers, etc.)
value == type_spec
end
end
end

def validate_array_element_type(value, element_type)
return false unless value.is_a?(Array)

value.all? { |el| el.is_a?(element_type) }
end

def save_union_error(record, attribute, value, union, validation_options)
return if validation_options[:message]

type_names = union.types.map do |type_spec|
if type_spec.is_a?(Array) && type_spec.length == 1
"Array of #{type_spec[0]}"
elsif type_spec.is_a?(Class) || type_spec == Boolean
type_spec.to_s
else
# For literal values like :active, show them as-is
type_spec.inspect
end
end

message = if type_names.length == 1
"is a #{value.class}, not one of #{type_names.first}"
else
"is a #{value.class}, not one of #{type_names.join(', ')}"
end

record.errors.add attribute, message
end
end

# Register the TypeValidator with ActiveModel so `type:` validation option works
Expand All @@ -146,6 +213,16 @@ def self.validated(*args, **kwargs, &block)
validates(*args, **kwargs, &block)
end

# Create a union type specification for validation
# @param types [Array] The types that are allowed
# @return [Union] A union type specification
# @example
# validates :id, type: union(String, Integer)
# validates :data, type: union(Hash, [Hash])
def self.union(*types)
Union.new(*types)
end

private

def set_instance_variables(from_hash:)
Expand Down
24 changes: 16 additions & 8 deletions lib/validated_object/simplified_api.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# frozen_string_literal: true

require 'active_support/concern'

# Enable a simplified API for the common case of
# read-only ValidatedObjects.
module ValidatedObject
# Enable a simplified API for the common case of
# read-only ValidatedObjects.
module SimplifiedApi
extend ActiveSupport::Concern

Expand All @@ -19,24 +21,30 @@ def validated(*args, **kwargs, &block)
validates(*args, **kwargs, &block)
end

# Alias for validated_attr for compatibility with test usage.
def validates_attr(attribute, *options, **kwargs)
attr_reader attribute

if kwargs[:type]
type_val = kwargs.delete(:type)
element_type = kwargs.delete(:element_type)

# Handle Union types - pass them through directly
if type_val.is_a?(ValidatedObject::Base::Union)
opts = { type: { with: type_val } }
validates attribute, opts.merge(kwargs)
# Parse Array[ElementType] syntax
if type_val.is_a?(Array) && type_val.length == 1 && type_val[0].is_a?(Class)
elsif type_val.is_a?(Array) && type_val.length == 1 && type_val[0].is_a?(Class)
# This handles Array[Comment] syntax
element_type = type_val[0]
type_val = Array
opts = { type: { with: type_val } }
opts[:type][:element_type] = element_type if element_type
validates attribute, opts.merge(kwargs)
else
opts = { type: { with: type_val } }
opts[:type][:element_type] = element_type if element_type
validates attribute, opts.merge(kwargs)
end

opts = { type: { with: type_val } }
opts[:type][:element_type] = element_type if element_type
validates attribute, opts.merge(kwargs)
else
validates attribute, *options, **kwargs
end
Expand Down
2 changes: 1 addition & 1 deletion lib/validated_object/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module ValidatedObject
VERSION = '2.3.3'
VERSION = '2.3.4'
end
93 changes: 93 additions & 0 deletions spec/union_types_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# frozen_string_literal: true

require 'spec_helper'
require 'validated_object'

#
# I needed to create actual classes for these specs to work.
# Therefore I namespaced them with Spec.
#
class SpecUnionComment; end

class SpecMultiType < ValidatedObject::Base
attr_accessor :id, :status, :data

validates :id, type: union(String, Integer)
validates :status, type: union(:active, :inactive, :pending)
validates :data, type: union(Hash, [Hash]), allow_nil: true
end

class SpecUnionAttr < ValidatedObject::Base
validates_attr :mixed, type: union(String, Integer, [String])
end

class SpecComplexUnion < ValidatedObject::Base
attr_accessor :flexible

validates :flexible, type: union(String, Integer, [String], [Hash])
end

class SpecSingleUnion < ValidatedObject::Base
attr_accessor :name

validates :name, type: union(String)
end

describe 'ValidatedObject Union Types' do
context 'when using union types' do
it 'accepts values matching first union type (String)' do
obj = SpecMultiType.new(id: 'abc123', status: :active, data: { key: 'value' })
expect(obj).to be_valid
end

it 'accepts values matching second union type (Integer)' do
obj = SpecMultiType.new(id: 42, status: :inactive, data: nil)
expect(obj).to be_valid
end

it 'accepts values matching array element type in union' do
obj = SpecMultiType.new(id: 'test', status: :pending, data: [{ a: 1 }, { b: 2 }])
expect(obj).to be_valid
end

it 'rejects values not matching any union type' do
expect do
SpecMultiType.new(id: 3.14, status: :active, data: {})
end.to raise_error(ArgumentError, /is a Float, not one of String, Integer/)
end

it 'rejects invalid symbol values in union' do
expect do
SpecMultiType.new(id: 'test', status: :invalid, data: {})
end.to raise_error(ArgumentError, /is a Symbol.*not one of.*active.*inactive.*pending/)
end

it 'works with validates_attr syntax' do
expect(SpecUnionAttr.new(mixed: 'text')).to be_valid
expect(SpecUnionAttr.new(mixed: 42)).to be_valid
expect(SpecUnionAttr.new(mixed: %w[a b c])).to be_valid

expect do
SpecUnionAttr.new(mixed: 3.14)
end.to raise_error(ArgumentError, /is a Float.*not one of.*String.*Integer.*Array of String/)
end

it 'handles complex union with multiple array types' do
expect(SpecComplexUnion.new(flexible: 'text')).to be_valid
expect(SpecComplexUnion.new(flexible: 123)).to be_valid
expect(SpecComplexUnion.new(flexible: %w[a b])).to be_valid
expect(SpecComplexUnion.new(flexible: [{ a: 1 }, { b: 2 }])).to be_valid

expect do
SpecComplexUnion.new(flexible: [1, 2, 3])
end.to raise_error(ArgumentError, /is a Array, not one of String, Integer, Array of String, Array of Hash/)
end

it 'works with single type union (equivalent to regular type validation)' do
expect(SpecSingleUnion.new(name: 'test')).to be_valid
expect do
SpecSingleUnion.new(name: 123)
end.to raise_error(ArgumentError, /is a Integer, not one of String/)
end
end
end