diff --git a/lib/validated_object.rb b/lib/validated_object.rb index 4705c89..611715d 100644 --- a/lib/validated_object.rb +++ b/lib/validated_object.rb @@ -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') @@ -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) @@ -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 @@ -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:) diff --git a/lib/validated_object/simplified_api.rb b/lib/validated_object/simplified_api.rb index 41560d7..12ac72d 100644 --- a/lib/validated_object/simplified_api.rb +++ b/lib/validated_object/simplified_api.rb @@ -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 @@ -19,7 +21,6 @@ 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 @@ -27,16 +28,23 @@ def validates_attr(attribute, *options, **kwargs) 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 diff --git a/lib/validated_object/version.rb b/lib/validated_object/version.rb index 788837c..809d346 100644 --- a/lib/validated_object/version.rb +++ b/lib/validated_object/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ValidatedObject - VERSION = '2.3.3' + VERSION = '2.3.4' end diff --git a/spec/union_types_spec.rb b/spec/union_types_spec.rb new file mode 100644 index 0000000..19b4594 --- /dev/null +++ b/spec/union_types_spec.rb @@ -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