diff --git a/config/locales/en.yml b/config/locales/en.yml index 35ca3e9..d79cf20 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -11,6 +11,8 @@ en: type: unknown_type: "Unknown type '%{type}' for attribute '%{attribute}'. Allowed types: %{allowed}" + option_type_mismatch: "Option 'type:' can only be used with 'object' attribute type, got '%{type}' for '%{attribute}'" + invalid_class: "Option 'type:' for attribute '%{attribute}' must be a Class, got %{value}" mismatch: integer: "Attribute '%{attribute}' must be an Integer, got %{actual}" string: "Attribute '%{attribute}' must be a String, got %{actual}" @@ -20,6 +22,7 @@ en: date: "Attribute '%{attribute}' must be a Date, got %{actual}" time: "Attribute '%{attribute}' must be a Time, got %{actual}" datetime: "Attribute '%{attribute}' must be a DateTime, got %{actual}" + class: "Attribute '%{attribute}' must be %{type}, got %{actual}" inclusion: invalid_schema: "Option 'inclusion' for attribute '%{attribute}' must have a non-empty array of allowed values" diff --git a/docs/api-reference.md b/docs/api-reference.md index 6943467..af08657 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -602,6 +602,7 @@ object :name **Parameters:** - `:name` - Symbol representing the object name - Special object: `:_self` - Merges attributes to parent level +- `type:` (Optional) - Class that value must be an instance of. Without this option, value must be a Hash. **Examples:** ```ruby @@ -618,6 +619,18 @@ end # Empty object object :metadata + +# Object with custom type validation +object :author, type: User do + string :name + string :email +end + +# Object with type: and use_entity (type-safe entity reuse) +object :author, type: Author do + use_entity(Shared::AuthorEntity) +end +# Value must be Author instance, attributes copied from entity ``` ## Attribute Types @@ -831,6 +844,22 @@ array :posts do end end +# Array of typed instances +array :users do + object :_self, type: User do + string :name + string :email + end +end + +# Array with type: and use_entity (type-safe entity reuse) +array :authors do + object :_self, type: Author do + use_entity(Shared::AuthorEntity) + end +end +# Each item must be Author instance, attributes copied from entity + # Empty array array :items, :optional ``` diff --git a/docs/attributes.md b/docs/attributes.md index 96dacdb..b55a6e4 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -86,7 +86,7 @@ object :author do end ``` -**Type validation:** Value must be a Ruby `Hash` +**Type validation:** Value must be a Ruby `Hash`, or an instance of a custom class if specified via `type:` option **See:** [Nested Structures](./nested-structures.md) for detailed information @@ -234,6 +234,51 @@ integer :rating, in: [1, 2, 3, 4, 5] **Validation:** Value must be one of the specified values. +#### type + +Specifies a custom class that object values must be instances of. **Only works with `object` attribute type.** + +```ruby +# Simple mode +object :author, type: User do + string :name + string :email +end + +# Advanced mode with custom message +object :author, type: { is: User, message: "Expected a User instance" } do + string :name + string :email +end + +# With :_self in arrays (array of typed instances) +array :users do + object :_self, type: User do + string :name + string :email + end +end +``` + +**Validation:** +- Without `type:` — value must be a `Hash` +- With `type: SomeClass` — value must be an instance of `SomeClass` + +**Note:** When `type:` is specified, ONLY instances of that class are accepted. Hash is NOT accepted. + +**Important:** Can only be used with `object` attribute type. Using `type:` on strings, integers, arrays, etc. raises a schema validation error. + +**With `use_entity` (typed object with entity reuse):** +```ruby +# Combine type: with use_entity for type-safe entity reuse +object :author, type: Author do + use_entity(Shared::AuthorEntity) +end +# Value must be Author instance, attributes copied from entity +``` + +**See:** [Validation: Type Validation](./validation.md#type-validation) for detailed examples + #### format Validates that string values match specific formats. **Only works with string type attributes.** diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 5737b98..b4831d8 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -79,6 +79,26 @@ array :socials, :optional do end ``` +### Combine use_entity with type: + +```ruby +# Type-safe entity reuse (only Author instances accepted) +object :author, type: Author do + use_entity(Shared::AuthorEntity) +end +# Data: Author.new(name: "John", email: "john@example.com") + +# In arrays +array :authors do + object :_self, type: Author do + use_entity(Shared::AuthorEntity) + end +end +# Data: [Author.new(name: "John"), Author.new(name: "Jane")] +``` + +**Note:** When `type:` is specified, Hash is NOT accepted. + ### Organize Entities ``` @@ -309,6 +329,11 @@ object :settings, :optional do string :theme end +# Object with custom type +object :author, type: User do + string :name +end + # Deeply nested object :post do object :author do @@ -361,6 +386,14 @@ end array :tags, :optional do string :_self end + +# Array of typed instances +array :users do + object :_self, type: User do + string :name + end +end +# Data: [User.new(name: "John"), User.new(name: "Jane")] ``` ## Request Definition diff --git a/docs/entities.md b/docs/entities.md index cf170f9..0c14454 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -312,6 +312,59 @@ Use `use_entity` in nested blocks when: - You want to maintain a single source of truth for the structure - The nested structure is complex enough to warrant its own class +### Entity Reuse with Custom Types + +Combine `use_entity` with `type:` option for type-safe entity reuse: + +```ruby +module Posts + module Create + class ResponseEntity < ApplicationEntity + object :post do + string :id + string :title + + # Reuse entity with type validation + object :author, type: Author do + use_entity(Shared::AuthorEntity) + end + + # In arrays + array :collaborators, :optional do + object :_self, type: Collaborator do + use_entity(Shared::CollaboratorEntity) + end + end + end + end + end +end +``` + +**How it works:** +- `type: Author` — validates that value is an `Author` instance +- `use_entity(Entity)` — copies attribute definitions for nested validation +- Values extracted via `author.name` (not `hash[:name]`) + +**Note:** When `type:` is specified, the value must be an instance of that class. +Hashes are NOT accepted. Use `type:` only when your service returns typed objects. + +**Expected data:** +```ruby +# Only Author/Collaborator instances accepted +{ + post: { + id: "123", + title: "Hello", + author: Author.new(name: "John", bio: "Developer"), + collaborators: [ + Collaborator.new(name: "Jane", role: "Editor"), + Collaborator.new(name: "Bob", role: "Reviewer") + ] + } +} +``` + ## Internal Architecture Understanding how Entity classes work internally: @@ -455,6 +508,20 @@ class ProductEntity < Treaty::Entity::Base # Validation string :category, in: %w[electronics clothing food] + # Custom type validation (only for objects) + object :author, type: Author do + string :name + string :email + end + + # Array of typed instances + array :collaborators, :optional do + object :_self, type: Collaborator do + string :name + string :role + end + end + # Computed values (derive from other attributes) string :display_name, :optional, computed: (lambda do |**attributes| "#{attributes.dig(:name)} (#{attributes.dig(:sku) || 'N/A'})" diff --git a/docs/internationalization.md b/docs/internationalization.md index 0487339..97685a7 100644 --- a/docs/internationalization.md +++ b/docs/internationalization.md @@ -70,6 +70,8 @@ treaty: type: unknown_type: "Unknown type '%{type}' for attribute '%{attribute}'. Allowed types: %{allowed}" + option_type_mismatch: "Option 'type:' can only be used with 'object' attribute type, got '%{type}' for '%{attribute}'" + invalid_class: "Option 'type:' for attribute '%{attribute}' must be a Class, got %{value}" mismatch: integer: "Attribute '%{attribute}' must be an Integer, got %{actual}" string: "Attribute '%{attribute}' must be a String, got %{actual}" @@ -79,6 +81,7 @@ treaty: date: "Attribute '%{attribute}' must be a Date, got %{actual}" time: "Attribute '%{attribute}' must be a Time, got %{actual}" datetime: "Attribute '%{attribute}' must be a DateTime, got %{actual}" + class: "Attribute '%{attribute}' must be %{type}, got %{actual}" inclusion: invalid_schema: "Option 'inclusion' for attribute '%{attribute}' must have a non-empty array of allowed values" @@ -213,6 +216,8 @@ de: type: unknown_type: "Unbekannter Typ '%{type}' für Attribut '%{attribute}'. Erlaubte Typen: %{allowed}" + option_type_mismatch: "Option 'type:' kann nur mit 'object' Attributtyp verwendet werden, '%{type}' für '%{attribute}' erhalten" + invalid_class: "Option 'type:' für Attribut '%{attribute}' muss eine Klasse sein, %{value} erhalten" mismatch: integer: "Attribut '%{attribute}' muss ein Integer sein, aber %{actual} erhalten" string: "Attribut '%{attribute}' muss ein String sein, aber %{actual} erhalten" @@ -222,6 +227,7 @@ de: date: "Attribut '%{attribute}' muss ein Date sein, aber %{actual} erhalten" time: "Attribut '%{attribute}' muss ein Time sein, aber %{actual} erhalten" datetime: "Attribut '%{attribute}' muss ein DateTime sein, aber %{actual} erhalten" + class: "Attribut '%{attribute}' muss %{type} sein, aber %{actual} erhalten" inclusion: invalid_schema: "Option 'inclusion' für Attribut '%{attribute}' muss ein nicht-leeres Array von erlaubten Werten haben" @@ -473,8 +479,9 @@ Different validators provide different interpolation variables: **Type validator:** - `%{attribute}` - the attribute name - `%{actual}` - the actual type received -- `%{type}` - the expected type +- `%{type}` - the expected type (or attribute type for option_type_mismatch) - `%{allowed}` - list of allowed types (for unknown_type) +- `%{value}` - the invalid value provided (for invalid_class) **Inclusion validator:** - `%{attribute}` - the attribute name diff --git a/docs/nested-structures.md b/docs/nested-structures.md index dfb3d58..f40b1d9 100644 --- a/docs/nested-structures.md +++ b/docs/nested-structures.md @@ -171,6 +171,87 @@ end **Each array item must be a hash** with the defined structure. +### Self-object Arrays (Typed Instances) + +Arrays where each item must be an instance of a specific class. Use `object :_self` with the `type:` option. + +```ruby +array :users do + object :_self, type: User do + string :name + string :email + end +end +``` + +**Expected data:** +```ruby +# Array of User instances +{ + users: [ + User.new(name: "John", email: "john@example.com"), + User.new(name: "Jane", email: "jane@example.com") + ] +} +``` + +**The `object :_self, type:` pattern:** +- `object :_self` indicates each array item is an object (not a primitive) +- `type: ClassName` specifies the expected class for validation +- Nested attributes define the structure for validation and transformation + +**Note:** When `type:` is specified, ONLY instances of that class are accepted. +Hash is NOT accepted. Without `type:`, only Hash is accepted. + +**When to use:** +- Working with ActiveRecord models or POROs in arrays +- Service layer returns arrays of typed objects +- Need type-safe validation of class instances + +### Combining type: with use_entity + +You can combine `type:` validation with `use_entity` for entity reuse: + +```ruby +# Object with custom type and entity reuse +object :author, type: Author do + use_entity(Shared::AuthorEntity) +end + +# In array context +array :authors do + object :_self, type: Author do + use_entity(Shared::AuthorEntity) + end +end +``` + +**How it works:** +- `type: Author` — validates that value is `Author` instance +- `use_entity(Entity)` — copies attribute definitions for nested validation +- Values are extracted via `author.name` (not `hash[:name]`) + +**When to use:** +- Services return typed PORO objects +- Need entity attribute reuse with type safety +- Working with ActiveRecord models + +**Expected data:** +```ruby +# Only Author instances accepted (NOT Hashes!) +{ + author: Author.new(name: "John", email: "john@example.com") +} + +# For arrays +{ + authors: [ + Author.new(name: "John", email: "john@example.com"), + Author.new(name: "Jane", email: "jane@example.com") + ] +} +``` + ### Required vs Optional Arrays ```ruby @@ -272,7 +353,7 @@ end ### Object Validation For objects: -1. Value must be a Hash +1. Value must be a Hash, or an instance of the class specified via `type:` option 2. All required attributes must be present 3. All attributes must match their types 4. Nested structures are validated recursively diff --git a/docs/objects.md b/docs/objects.md index 0cfafc4..44fb39a 100644 --- a/docs/objects.md +++ b/docs/objects.md @@ -8,7 +8,7 @@ Objects group related attributes together, creating nested structures in your re ## What are Objects? -Objects group related attributes together, creating nested structures in your request and response data. Use the `object` attribute type to organize your API data into logical hierarchies. +Objects group related attributes together, creating nested structures in your request and response data. Use the `object` attribute type to organize your API data into logical hierarchies. By default, object values must be Ruby Hashes, but you can specify a custom class using the `type:` option (see [Attributes: type option](./attributes.md#type)). ## Basic Object Grouping diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1b9f5aa..bfa6e3d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -216,6 +216,71 @@ end { "authors" => ["John Doe", "John Doe"] } # ✗ Strings instead of objects ``` +### "Option 'type:' can only be used with 'object' attribute type" + +**Problem:** Trying to use `type:` option on non-object attribute. + +**Solution:** +The `type:` option is only valid for `object` attributes. Remove it from other attribute types. + +**Example:** +```ruby +# Correct: +object :author, type: User do # ✓ type: works with object + string :name +end + +# Incorrect: +string :name, type: String # ✗ type: doesn't work with string +array :items, type: Array # ✗ type: doesn't work with array +``` + +### "Option 'type:' for attribute 'X' must be a Class" + +**Problem:** The value provided to `type:` option is not a Class. + +**Solution:** +Ensure the `type:` option receives a Class constant, not a string, symbol, or instance. + +**Example:** +```ruby +# Correct: +object :author, type: User do # ✓ User is a Class + string :name +end + +# Incorrect: +object :author, type: "User" do # ✗ String, not Class + string :name +end + +object :author, type: :user do # ✗ Symbol, not Class + string :name +end +``` + +### "Attribute 'X' must be CLASS, got TYPE" + +**Problem:** Object value is not an instance of the expected class. + +**Solution:** +Provide either a Hash or an instance of the specified class. + +**Example:** +```ruby +# Treaty expects: +object :author, type: User do + string :name +end + +# Send: +{ "author" => User.new(name: "John") } # ✓ User instance +{ "author" => { "name" => "John" } } # ✓ Hash also works + +# Not: +{ "author" => "John" } # ✗ String instead of User or Hash +``` + ## Version Issues ### Treaty not using expected version diff --git a/docs/validation.md b/docs/validation.md index ffc4417..706e857 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -124,6 +124,100 @@ end { published_at: "2024-01-01" } ✗ # Must be DateTime/Time, got String ``` +### Custom Type for Objects + +Object attributes can validate against a custom class instead of Hash: + +```ruby +# Define a class +class Author + attr_accessor :name, :email + + def initialize(name:, email:) + @name = name + @email = email + end +end + +# Use in treaty definition +request do + object :author, type: Author do + string :name + string :email + end +end +``` + +**Valid data:** +```ruby +# Only Author instances accepted when type: is specified +author = Author.new(name: "John", email: "john@example.com") +{ author: author } +``` + +**Note:** When `type:` is specified, ONLY instances of that class are accepted. +Hash is NOT accepted. Without `type:`, only Hash is accepted. + +**Invalid data:** +```ruby +{ author: "John Doe" } +# Error: Attribute 'author' must be Author, got String +``` + +**With custom error message:** +```ruby +object :author, type: { is: Author, message: "Author must be an Author instance" } do + string :name +end +``` + +#### In Arrays with `object :_self` + +Custom types work with `object :_self` in arrays: + +```ruby +array :authors do + object :_self, type: Author do + string :name + string :email + end +end +``` + +**Valid data:** +```ruby +# Only Author instances accepted when type: is specified +{ authors: [Author.new(name: "John", email: "john@example.com")] } +``` + +**Note:** When `type:` is specified, ONLY instances of that class are accepted. +Hash is NOT accepted in the array. + +#### Combining type: with use_entity + +Custom types work with entity reuse: + +```ruby +object :author, type: Author do + use_entity(Shared::AuthorEntity) # Copies: string :name, string :email +end +``` + +**Validation flow:** +1. Type check: `value.is_a?(Author)` — must pass! +2. Nested attributes: extracted via `author.name`, `author.email` +3. Each attribute validated against entity definition + +**Important:** Hash is NOT accepted when `type:` is specified. + +```ruby +# ✅ Valid +{ author: Author.new(name: "John", email: "john@example.com") } + +# ❌ Invalid (raises validation error) +{ author: { name: "John", email: "john@example.com" } } +``` + ### Inclusion Validation Restricts values to a predefined list. diff --git a/lib/treaty/entity/attribute/base.rb b/lib/treaty/entity/attribute/base.rb index 3618f6f..a20af13 100644 --- a/lib/treaty/entity/attribute/base.rb +++ b/lib/treaty/entity/attribute/base.rb @@ -113,8 +113,35 @@ def array? @type == :array end + # Returns the custom type class if specified via type: option + # Returns nil if no custom type or type option contains non-class value + # + # @return [Class, nil] Custom type class or nil + def custom_type + @custom_type ||= extract_custom_type + end + + # Checks if this attribute has a custom type specified + # + # @return [Boolean] True if custom type is defined + def custom_type? + !custom_type.nil? + end + private + # Extracts custom type from options + # After OptionNormalizer, type: is always in advanced mode: + # - type: { is: User, message: nil } -> User + # + # @return [Class, nil] Custom type class or nil + def extract_custom_type + return nil unless options.key?(:type) + + type_value = options[:type].fetch(:is, nil) + type_value.is_a?(Class) ? type_value : nil + end + # Validates that nesting level doesn't exceed maximum allowed depth # # @raise [Treaty::Exceptions::NestedAttributes] If nesting exceeds limit diff --git a/lib/treaty/entity/attribute/option/validators/type_validator.rb b/lib/treaty/entity/attribute/option/validators/type_validator.rb index f24fa77..0999f70 100644 --- a/lib/treaty/entity/attribute/option/validators/type_validator.rb +++ b/lib/treaty/entity/attribute/option/validators/type_validator.rb @@ -49,22 +49,58 @@ module Validators # # TypeValidator doesn't use option_schema - it validates based on attribute_type. # This validator is always active for all attributes. - class TypeValidator < Treaty::Entity::Attribute::Option::Base + class TypeValidator < Treaty::Entity::Attribute::Option::Base # rubocop:disable Metrics/ClassLength ALLOWED_TYPES = %i[integer string boolean object array date time datetime].freeze # Validates that the attribute type is one of the allowed types + # and validates type: option if present # # @raise [Treaty::Exceptions::Validation] If type is not allowed + # @raise [Treaty::Exceptions::Validation] If type: option is invalid # @return [void] def validate_schema! - return if ALLOWED_TYPES.include?(@attribute_type) + unless ALLOWED_TYPES.include?(@attribute_type) + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.validators.type.unknown_type", + type: @attribute_type, + attribute: @attribute_name, + allowed: ALLOWED_TYPES.join(", ") + ) + end + + validate_custom_type_schema! + end + + # Validates that type: option is used correctly + # Called during schema validation phase + # + # @raise [Treaty::Exceptions::Validation] If type: used with non-object attribute + # @raise [Treaty::Exceptions::Validation] If type: value is not a Class + # @return [void] + def validate_custom_type_schema! # rubocop:disable Metrics/MethodLength + return unless @option_schema + + # type: option only works with object type + unless @attribute_type == :object + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.validators.type.option_type_mismatch", + attribute: @attribute_name, + type: @attribute_type + ) + end + + # Validate that value is a Class + type_value = @option_schema.fetch(:is, nil) + return if type_value.nil? # No custom type, Hash expected + return if type_value.is_a?(Class) raise Treaty::Exceptions::Validation, I18n.t( - "treaty.attributes.validators.type.unknown_type", - type: @attribute_type, + "treaty.attributes.validators.type.invalid_class", attribute: @attribute_name, - allowed: ALLOWED_TYPES.join(", ") + value: type_value.inspect ) end @@ -165,13 +201,67 @@ def validate_boolean!(value) validate_type!(value, :boolean) { |v| v.is_a?(TrueClass) || v.is_a?(FalseClass) } end - # Validates that value is a Hash (object type) + # Validates that value is a Hash or custom type class # # @param value [Object] The value to validate - # @raise [Treaty::Exceptions::Validation] If value is not a Hash + # @raise [Treaty::Exceptions::Validation] If value is not expected type # @return [void] def validate_object!(value) - validate_type!(value, :object) { |v| v.is_a?(Hash) } + custom_type_class = extract_custom_type_from_schema + + if custom_type_class + validate_custom_type!(value, custom_type_class) + else + validate_type!(value, :object) { |v| v.is_a?(Hash) } + end + end + + # Extracts custom type class from option_schema + # After OptionNormalizer, option_schema is always in advanced mode: + # { is: User, message: nil } + # + # @return [Class, nil] Custom type class or nil + def extract_custom_type_from_schema + return nil if @option_schema.nil? + + type_value = @option_schema.fetch(:is, nil) + type_value.is_a?(Class) ? type_value : nil + end + + # Validates that value is an instance of custom type class + # + # @param value [Object] The value to validate + # @param expected_class [Class] Expected class + # @raise [Treaty::Exceptions::Validation] If value is not expected class + # @return [void] + def validate_custom_type!(value, expected_class) + return if value.is_a?(expected_class) + + attributes = { + attribute: @attribute_name, + value:, + type: expected_class.name || expected_class.inspect, + actual: value.class + } + + message = resolve_custom_message(**attributes) || default_class_message(**attributes) + + raise Treaty::Exceptions::Validation, message + end + + # Generates default error message for class type mismatch using I18n + # + # @param attribute [Symbol] The attribute name + # @param type [String] The expected class name + # @param actual [Class] The actual class of the value + # @return [String] Default error message + def default_class_message(attribute:, type:, actual:, **) + I18n.t( + "treaty.attributes.validators.type.mismatch.class", + attribute:, + type:, + actual: + ) end # Validates that value is an Array diff --git a/lib/treaty/entity/attribute/option_normalizer.rb b/lib/treaty/entity/attribute/option_normalizer.rb index 4c7943e..0a7713c 100644 --- a/lib/treaty/entity/attribute/option_normalizer.rb +++ b/lib/treaty/entity/attribute/option_normalizer.rb @@ -72,7 +72,8 @@ class OptionNormalizer in: { advanced_key: :inclusion, value_key: :in }, as: { advanced_key: :as, value_key: :is }, default: { advanced_key: :default, value_key: :is }, - cast: { advanced_key: :cast, value_key: :to } + cast: { advanced_key: :cast, value_key: :to }, + type: { advanced_key: :type, value_key: :is } }.freeze private_constant :OPTION_KEY_MAPPING diff --git a/lib/treaty/entity/attribute/validation/concerns/conditional_processing.rb b/lib/treaty/entity/attribute/validation/concerns/conditional_processing.rb new file mode 100644 index 0000000..4d791bb --- /dev/null +++ b/lib/treaty/entity/attribute/validation/concerns/conditional_processing.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Treaty + module Entity + module Attribute + module Validation + module Concerns + # Provides conditional attribute processing for nested transformers. + # + # ## Purpose + # + # Handles :if and :unless options for nested attributes during transformation. + # Extracts duplicated conditional logic from ObjectTransformer and ArrayTransformer. + # + # ## Responsibilities + # + # 1. **Conditional Detection** - Identifies :if/:unless options on attributes + # 2. **Mutual Exclusivity** - Ensures :if and :unless aren't used together + # 3. **Processor Caching** - Builds and caches conditional processors + # 4. **Condition Evaluation** - Evaluates conditions with source data + # + # ## Requirements + # + # Including class must provide: + # - `attribute` method returning Attribute::Base with collection_of_attributes + # + # ## Usage + # + # class ObjectTransformer + # include Concerns::ConditionalProcessing + # + # def transform(value) + # attribute.collection_of_attributes.each do |nested_attribute| + # next unless should_process_attribute?(nested_attribute, value) + # # process attribute... + # end + # end + # end + module ConditionalProcessing + private + + # Returns the conditional option name if present (:if or :unless) + # Raises error if both are present (mutual exclusivity) + # + # @param nested_attribute [Attribute::Base] The attribute to check + # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present + # @return [Symbol, nil] :if, :unless, or nil + def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength + has_if = nested_attribute.options.key?(:if) + has_unless = nested_attribute.options.key?(:unless) + + if has_if && has_unless + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.conditionals.mutual_exclusivity_error", + attribute: nested_attribute.name + ) + end + + return :if if has_if + return :unless if has_unless + + nil + end + + # Gets cached conditional processors for attributes or builds them + # + # @return [Hash] Hash of attribute => conditional processor + def conditionals_for_attributes + @conditionals_for_attributes ||= build_conditionals_for_attributes + end + + # Builds conditional processors for attributes with :if or :unless option + # Validates schema at definition time for performance + # + # @return [Hash] Hash of attribute => conditional processor + def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength + attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache| + # Get conditional option name (:if or :unless) + conditional_type = conditional_option_for(nested_attribute) + next if conditional_type.nil? + + processor_class = Option::Registry.processor_for(conditional_type) + next if processor_class.nil? + + # Create processor instance + conditional = processor_class.new( + attribute_name: nested_attribute.name, + attribute_type: nested_attribute.type, + option_schema: nested_attribute.options.fetch(conditional_type) + ) + + # Validate schema at definition time (not runtime) + conditional.validate_schema! + + cache[nested_attribute] = conditional + end + end + + # Checks if an attribute should be processed based on its conditional + # Returns true if no conditional is defined or if conditional evaluates appropriately + # + # @param nested_attribute [Attribute::Base] The attribute to check + # @param source_data [Hash, Object] Source data to pass to conditional + # @return [Boolean] True if attribute should be processed, false to skip it + def should_process_attribute?(nested_attribute, source_data) + # Check if attribute has a conditional option + conditional_type = conditional_option_for(nested_attribute) + return true if conditional_type.nil? + + # Get cached conditional processor + conditional = conditionals_for_attributes[nested_attribute] + return true if conditional.nil? + + # Evaluate condition with source data wrapped with parent attribute name + wrapped_data = { attribute.name => source_data } + conditional.evaluate_condition(wrapped_data) + rescue StandardError + # If conditional evaluation fails, skip the attribute + false + end + end + end + end + end + end +end diff --git a/lib/treaty/entity/attribute/validation/nested_array_validator.rb b/lib/treaty/entity/attribute/validation/nested_array_validator.rb index 276eaf4..8831004 100644 --- a/lib/treaty/entity/attribute/validation/nested_array_validator.rb +++ b/lib/treaty/entity/attribute/validation/nested_array_validator.rb @@ -64,9 +64,10 @@ module Validation # # Uses: # - `AttributeValidator` - Validates individual elements + # - `ValueExtractor` - Extracts values from Hash or PORO polymorphically # - Caches validators for performance # - Separates self validators from regular validators - class NestedArrayValidator + class NestedArrayValidator # rubocop:disable Metrics/ClassLength # Creates a new nested array validator # # @param attribute [Attribute::Base] The array-type attribute with nested attributes @@ -74,6 +75,7 @@ def initialize(attribute) @attribute = attribute @self_validators = nil @regular_validators = nil + @self_object_attribute = nil end # Validates all items in an array @@ -124,15 +126,109 @@ def validate_self_array_item!(array_item, index) # rubocop:disable Metrics/Metho ) end - # Validates array item for complex arrays (with regular attributes) - # Complex array contains hash objects with defined structure - # Example: [{ name: "Alice", email: "alice@example.com" }, ...] where each item is a Hash + # Validates array item for complex arrays (with regular attributes or object :_self) # - # @param array_item [Hash] Hash object from complex array + # @param array_item [Hash, Object] Item from array # @param index [Integer] Array index for error messages - # @raise [Treaty::Exceptions::Validation] If item is not Hash or nested validation fails + # @raise [Treaty::Exceptions::Validation] If validation fails # @return [void] - def validate_regular_array_item!(array_item, index) # rubocop:disable Metrics/MethodLength + def validate_regular_array_item!(array_item, index) + if self_object_attribute + validate_self_object_item!(array_item, index, self_object_attribute) + else + validate_hash_array_item!(array_item, index) + end + end + + # Gets cached self-object attribute or finds it + # + # @return [Attribute::Base, nil] Self-object attribute or nil + def self_object_attribute + return @self_object_attribute if defined?(@self_object_attribute_loaded) + + @self_object_attribute_loaded = true + @self_object_attribute = find_self_object_attribute + end + + # Finds object :_self attribute if present + # + # @return [Attribute::Base, nil] Self-object attribute or nil + def find_self_object_attribute + @attribute.collection_of_attributes.find do |nested_attribute| + nested_attribute.name == :_self && nested_attribute.type == :object + end + end + + # Validates array item against object :_self definition + # + # @param array_item [Hash, Object] Item from array + # @param index [Integer] Array index for error messages + # @param self_object_attribute [Attribute::Base] The object :_self attribute + # @raise [Treaty::Exceptions::Validation] If validation fails + # @return [void] + def validate_self_object_item!(array_item, index, self_object_attribute) + validate_self_object_type!(array_item, index, self_object_attribute) + + return unless self_object_attribute.nested? + + validate_self_object_attributes!(array_item, index, self_object_attribute) + end + + # Validates type of self-object array item + # + # @param array_item [Hash, Object] Item from array + # @param index [Integer] Array index for error messages + # @param self_object_attribute [Attribute::Base] The object :_self attribute + # @raise [Treaty::Exceptions::Validation] If type doesn't match + # @return [void] + def validate_self_object_type!(array_item, index, self_object_attribute) + expected_type = self_object_attribute.custom_type || Hash + + return if array_item.is_a?(expected_type) + + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.validators.nested.array.element_type_error", + attribute: @attribute.name, + index:, + actual: array_item.class + ) + end + + # Validates nested attributes of self-object array item + # + # @param array_item [Hash, Object] Item from array + # @param index [Integer] Array index for error messages + # @param self_object_attribute [Attribute::Base] The object :_self attribute + # @raise [Treaty::Exceptions::Validation] If nested validation fails + # @return [void] + def validate_self_object_attributes!(array_item, index, self_object_attribute) # rubocop:disable Metrics/MethodLength + self_object_attribute.collection_of_attributes.each do |nested_attribute| + nested_value = ValueExtractor.extract(array_item, nested_attribute.name) + validator = AttributeValidator.new(nested_attribute) + validator.validate_schema! + + begin + validator.validate_value!(nested_value) + rescue Treaty::Exceptions::Validation => e + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.validators.nested.array.attribute_error", + attribute: @attribute.name, + index:, + message: e.message + ) + end + end + end + + # Validates array item as Hash (original behavior) + # + # @param array_item [Hash] Hash object from array + # @param index [Integer] Array index for error messages + # @raise [Treaty::Exceptions::Validation] If item is not Hash or validation fails + # @return [void] + def validate_hash_array_item!(array_item, index) # rubocop:disable Metrics/MethodLength unless array_item.is_a?(Hash) raise Treaty::Exceptions::Validation, I18n.t( @@ -180,7 +276,7 @@ def regular_validators # @return [Array] Array of validators def build_self_validators @attribute.collection_of_attributes - .select { |attr| attr.name == :_self } + .select { |nested_attribute| nested_attribute.name == :_self } .map do |self_attribute| validator = AttributeValidator.new(self_attribute) validator.validate_schema! @@ -193,7 +289,7 @@ def build_self_validators # @return [Hash] Hash of nested_attribute => validator def build_regular_validators @attribute.collection_of_attributes - .reject { |attr| attr.name == :_self } + .reject { |nested_attribute| nested_attribute.name == :_self } .each_with_object({}) do |nested_attribute, cache| validator = AttributeValidator.new(nested_attribute) validator.validate_schema! diff --git a/lib/treaty/entity/attribute/validation/nested_object_validator.rb b/lib/treaty/entity/attribute/validation/nested_object_validator.rb index cffda93..5f7a7e8 100644 --- a/lib/treaty/entity/attribute/validation/nested_object_validator.rb +++ b/lib/treaty/entity/attribute/validation/nested_object_validator.rb @@ -52,6 +52,7 @@ module Validation # # Uses: # - `AttributeValidator` - Validates individual nested attributes + # - `ValueExtractor` - Extracts values from Hash or PORO polymorphically # - Caches validators to avoid rebuilding on each validation class NestedObjectValidator attr_reader :attribute @@ -64,17 +65,18 @@ def initialize(attribute) @validators_cache = nil end - # Validates all nested attributes in a hash - # Skips validation if value is not a Hash + # Validates all nested attributes in a hash or custom type object + # Skips validation if value type doesn't match expected type # - # @param hash [Hash] The hash to validate + # @param value [Hash, Object] The value to validate (Hash or PORO) # @raise [Treaty::Exceptions::Validation] If any nested validation fails # @return [void] - def validate!(hash) - return unless hash.is_a?(Hash) + def validate!(value) + expected_type = attribute.custom_type || Hash + return unless value.is_a?(expected_type) validators.each do |nested_attribute, nested_validator| - nested_value = hash.fetch(nested_attribute.name, nil) + nested_value = ValueExtractor.extract(value, nested_attribute.name) nested_validator.validate_value!(nested_value) end end diff --git a/lib/treaty/entity/attribute/validation/nested_transformer.rb b/lib/treaty/entity/attribute/validation/nested_transformer.rb index bf63a0b..3452bfb 100644 --- a/lib/treaty/entity/attribute/validation/nested_transformer.rb +++ b/lib/treaty/entity/attribute/validation/nested_transformer.rb @@ -66,6 +66,8 @@ def transform_array(value, root_data = {}) # Transforms object (hash) with nested attributes class ObjectTransformer + include Concerns::ConditionalProcessing + attr_reader :attribute # Creates a new object transformer @@ -95,98 +97,17 @@ def transform(value, root_data = {}) private - # Returns the conditional option name if present (:if or :unless) - # Raises error if both are present (mutual exclusivity) - # - # @param nested_attribute [Attribute::Base] The attribute to check - # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present - # @return [Symbol, nil] :if, :unless, or nil - def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength - has_if = nested_attribute.options.key?(:if) - has_unless = nested_attribute.options.key?(:unless) - - if has_if && has_unless - raise Treaty::Exceptions::Validation, - I18n.t( - "treaty.attributes.conditionals.mutual_exclusivity_error", - attribute: nested_attribute.name - ) - end - - return :if if has_if - return :unless if has_unless - - nil - end - - # Gets cached conditional processors for attributes or builds them - # - # @return [Hash] Hash of attribute => conditional processor - def conditionals_for_attributes - @conditionals_for_attributes ||= build_conditionals_for_attributes - end - - # Builds conditional processors for attributes with :if or :unless option - # Validates schema at definition time for performance - # - # @return [Hash] Hash of attribute => conditional processor - def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength - attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache| - # Get conditional option name (:if or :unless) - conditional_type = conditional_option_for(nested_attribute) - next if conditional_type.nil? - - processor_class = Option::Registry.processor_for(conditional_type) - next if processor_class.nil? - - # Create processor instance - conditional = processor_class.new( - attribute_name: nested_attribute.name, - attribute_type: nested_attribute.type, - option_schema: nested_attribute.options.fetch(conditional_type) - ) - - # Validate schema at definition time (not runtime) - conditional.validate_schema! - - cache[nested_attribute] = conditional - end - end - - # Checks if an attribute should be processed based on its conditional (if/unless option) - # Returns true if no conditional is defined or if conditional evaluates appropriately - # - # @param nested_attribute [Attribute::Base] The attribute to check - # @param source_hash [Hash] Source data to pass to conditional - # @return [Boolean] True if attribute should be processed, false to skip it - def should_process_attribute?(nested_attribute, source_hash) - # Check if attribute has a conditional option - conditional_type = conditional_option_for(nested_attribute) - return true if conditional_type.nil? - - # Get cached conditional processor - conditional = conditionals_for_attributes[nested_attribute] - return true if conditional.nil? - - # Evaluate condition with source hash data wrapped with parent object name - wrapped_data = { attribute.name => source_hash } - conditional.evaluate_condition(wrapped_data) - rescue StandardError - # If conditional evaluation fails, skip the attribute - false - end - # Processes a single nested attribute # Validates, transforms, and adds to target hash # # @param nested_attribute [Attribute::Base] Attribute to process - # @param source_hash [Hash] Source data + # @param source [Hash, Object] Source data (Hash or PORO) # @param target_hash [Hash] Target hash to populate # @param root_data [Hash] Full raw data from root level (for computed modifier) # @return [void] - def process_attribute(nested_attribute, source_hash, target_hash, root_data = {}) # rubocop:disable Metrics/MethodLength + def process_attribute(nested_attribute, source, target_hash, root_data = {}) # rubocop:disable Metrics/MethodLength source_name = nested_attribute.name - nested_value = source_hash.fetch(source_name, nil) + nested_value = ValueExtractor.extract(source, source_name) validator = AttributeValidator.new(nested_attribute) validator.validate_schema! @@ -208,6 +129,8 @@ def process_attribute(nested_attribute, source_hash, target_hash, root_data = {} # Transforms array with nested attributes class ArrayTransformer # rubocop:disable Metrics/ClassLength + include Concerns::ConditionalProcessing + SELF_OBJECT = :_self private_constant :SELF_OBJECT @@ -221,7 +144,10 @@ def initialize(attribute) end # Transforms array by processing each element - # Handles both simple arrays (:_self) and complex arrays (objects) + # Handles three array types: + # - Simple arrays (primitives like string :_self) + # - Self-object arrays (object :_self pattern) + # - Complex arrays (regular attributes) # # @param value [Array] The source array # @param root_data [Hash] Full raw data from root level (for computed modifier) @@ -230,6 +156,8 @@ def transform(value, root_data = {}) value.each_with_index.map do |item, index| if simple_array? transform_simple_element(item, index, root_data) + elsif self_object_array? + transform_self_object_element(item, index, root_data) else transform_array_item(item, index, root_data) end @@ -238,93 +166,99 @@ def transform(value, root_data = {}) private - # Returns the conditional option name if present (:if or :unless) - # Raises error if both are present (mutual exclusivity) + # Checks if this is a simple array (primitive values) + # Excludes object :_self which is handled separately + # + # @return [Boolean] True if array contains primitive values with :_self attribute + def simple_array? + return false unless attribute.collection_of_attributes.size == 1 + + first_attr = attribute.collection_of_attributes.first + first_attr.name == SELF_OBJECT && first_attr.type != :object + end + + # Checks if this is a self-object array (object :_self pattern) + # + # @return [Boolean] True if array uses object :_self pattern + def self_object_array? + return false unless attribute.collection_of_attributes.size == 1 + + first_attr = attribute.collection_of_attributes.first + first_attr.name == SELF_OBJECT && first_attr.type == :object + end + + # Transforms a self-object array element (object :_self pattern) # - # @param nested_attribute [Attribute::Base] The attribute to check - # @raise [Treaty::Exceptions::Validation] If both :if and :unless are present - # @return [Symbol, nil] :if, :unless, or nil - def conditional_option_for(nested_attribute) # rubocop:disable Metrics/MethodLength - has_if = nested_attribute.options.key?(:if) - has_unless = nested_attribute.options.key?(:unless) - - if has_if && has_unless + # @param item [Hash, Object] Array element to transform (Hash or PORO) + # @param index [Integer] Element index for error messages + # @param root_data [Hash] Full raw data from root level (for computed modifier) + # @raise [Treaty::Exceptions::Validation] If item type doesn't match expected + # @return [Hash] Transformed hash + def transform_self_object_element(item, index, root_data = {}) # rubocop:disable Metrics/MethodLength + self_object_attribute = attribute.collection_of_attributes.first + expected_type = self_object_attribute.custom_type || Hash + + unless item.is_a?(expected_type) raise Treaty::Exceptions::Validation, I18n.t( - "treaty.attributes.conditionals.mutual_exclusivity_error", - attribute: nested_attribute.name + "treaty.attributes.validators.nested.array.element_type_error", + attribute: attribute.name, + index:, + actual: item.class ) end - return :if if has_if - return :unless if has_unless + return item unless self_object_attribute.nested? - nil - end + transformed = {} - # Gets cached conditional processors for attributes or builds them - # - # @return [Hash] Hash of attribute => conditional processor - def conditionals_for_attributes - @conditionals_for_attributes ||= build_conditionals_for_attributes - end + self_object_attribute.collection_of_attributes.each do |nested_attribute| + next unless should_process_attribute?(nested_attribute, item) - # Builds conditional processors for attributes with :if or :unless option - # Validates schema at definition time for performance - # - # @return [Hash] Hash of attribute => conditional processor - def build_conditionals_for_attributes # rubocop:disable Metrics/MethodLength - attribute.collection_of_attributes.each_with_object({}) do |nested_attribute, cache| - # Get conditional option name (:if or :unless) - conditional_type = conditional_option_for(nested_attribute) - next if conditional_type.nil? - - processor_class = Option::Registry.processor_for(conditional_type) - next if processor_class.nil? - - # Create processor instance - conditional = processor_class.new( - attribute_name: nested_attribute.name, - attribute_type: nested_attribute.type, - option_schema: nested_attribute.options.fetch(conditional_type) - ) - - # Validate schema at definition time (not runtime) - conditional.validate_schema! - - cache[nested_attribute] = conditional + process_self_object_attribute(nested_attribute, item, transformed, index, root_data) end - end - # Checks if an attribute should be processed based on its conditional (if/unless option) - # Returns true if no conditional is defined or if conditional evaluates appropriately - # - # @param nested_attribute [Attribute::Base] The attribute to check - # @param source_hash [Hash] Source data to pass to conditional - # @return [Boolean] True if attribute should be processed, false to skip it - def should_process_attribute?(nested_attribute, source_hash) - # Check if attribute has a conditional option - conditional_type = conditional_option_for(nested_attribute) - return true if conditional_type.nil? - - # Get cached conditional processor - conditional = conditionals_for_attributes[nested_attribute] - return true if conditional.nil? - - # Evaluate condition with source hash data wrapped with parent array attribute name - wrapped_data = { attribute.name => source_hash } - conditional.evaluate_condition(wrapped_data) - rescue StandardError - # If conditional evaluation fails, skip the attribute - false + transformed end - # Checks if this is a simple array (primitive values) + # Processes a single nested attribute in self-object array element # - # @return [Boolean] True if array contains primitive values with :_self attribute - def simple_array? - attribute.collection_of_attributes.size == 1 && - attribute.collection_of_attributes.first.name == SELF_OBJECT + # @param nested_attribute [Attribute::Base] Attribute to process + # @param source [Hash, Object] Source data (Hash or PORO) + # @param target_hash [Hash] Target hash to populate + # @param index [Integer] Array index for error messages + # @param root_data [Hash] Full raw data from root level (for computed modifier) + # @raise [Treaty::Exceptions::Validation] If validation fails + # @return [void] + def process_self_object_attribute(nested_attribute, source, target_hash, index, root_data = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + source_name = nested_attribute.name + nested_value = ValueExtractor.extract(source, source_name) + + validator = AttributeValidator.new(nested_attribute) + validator.validate_schema! + + begin + transformed_value = if nested_attribute.nested? + nested_transformer = NestedTransformer.new(nested_attribute) + validator.validate_type!(nested_value) unless nested_value.nil? + validator.validate_required!(nested_value) + nested_transformer.transform(nested_value, root_data) + else + validator.validate_value!(nested_value) + validator.transform_value(nested_value, root_data) + end + rescue Treaty::Exceptions::Validation => e + raise Treaty::Exceptions::Validation, + I18n.t( + "treaty.attributes.validators.nested.array.attribute_error", + attribute: attribute.name, + index:, + message: e.message + ) + end + + target_name = validator.target_name + target_hash[target_name] = transformed_value end # Transforms a simple array element (primitive value) diff --git a/lib/treaty/entity/attribute/validation/value_extractor.rb b/lib/treaty/entity/attribute/validation/value_extractor.rb new file mode 100644 index 0000000..54349e4 --- /dev/null +++ b/lib/treaty/entity/attribute/validation/value_extractor.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Treaty + module Entity + module Attribute + module Validation + # Extracts values from Hash or PORO objects polymorphically. + # + # ## Purpose + # + # Provides unified interface for accessing attribute values from: + # - Hash objects (using fetch with symbol keys) + # - PORO objects (using public_send) + # + # ## Usage + # + # ValueExtractor.extract({ name: "Alice" }, :name) # => "Alice" + # ValueExtractor.extract(user_object, :name) # => user_object.name + # + # ## Missing Values + # + # Returns nil when: + # - Hash key doesn't exist + # - PORO doesn't respond to the method + # - Method is private (respond_to? returns false) + # + # RequiredValidator handles nil values appropriately. + class ValueExtractor + class << self + # Extracts value from source by key + # + # @param source [Hash, Object] Source data (Hash or PORO) + # @param key [Symbol] Attribute name to extract + # @return [Object, nil] Extracted value or nil if not found + def extract(source, key) + case source + when Hash + source.fetch(key, nil) + else + source.respond_to?(key) ? source.public_send(key) : nil + end + end + end + end + end + end + end +end diff --git a/spec/treaty/entity/attribute/option/validators/type_validator_spec.rb b/spec/treaty/entity/attribute/option/validators/type_validator_spec.rb index bb1e136..a6027e9 100644 --- a/spec/treaty/entity/attribute/option/validators/type_validator_spec.rb +++ b/spec/treaty/entity/attribute/option/validators/type_validator_spec.rb @@ -418,4 +418,228 @@ end end end + + describe "custom type validation" do + subject(:validator) do + described_class.new( + attribute_name:, + attribute_type:, + option_schema: + ) + end + + let(:attribute_name) { :author } + let(:attribute_type) { :object } + + let(:user_class) do + Class.new do + attr_reader :name, :email + + def initialize(name:, email:) + @name = name + @email = email + end + + def self.name + "User" + end + end + end + + let(:admin_class) do + Class.new do + attr_reader :name, :permissions + + def initialize(name:, permissions:) + @name = name + @permissions = permissions + end + + def self.name + "Admin" + end + end + end + + describe "#validate_schema!" do + context "when type: option is used with object attribute" do + let(:option_schema) { { is: user_class, message: nil } } + + it "does not raise an error" do + expect { validator.validate_schema! }.not_to raise_error + end + end + + context "when type: option is used with non-object attribute" do + let(:attribute_type) { :string } + let(:option_schema) { { is: user_class, message: nil } } + + it "raises validation error", :aggregate_failures do + expect { validator.validate_schema! }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("type:") + expect(exception.message).to include("object") + expect(exception.message).to include("string") + end + ) + end + end + + context "when type: option is not a Class" do + let(:option_schema) { { is: "User", message: nil } } + + it "raises validation error", :aggregate_failures do + expect { validator.validate_schema! }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("type:") + expect(exception.message).to include("Class") + end + ) + end + end + + context "when type: option is nil (default Hash expected)" do + let(:option_schema) { { is: nil, message: nil } } + + it "does not raise an error" do + expect { validator.validate_schema! }.not_to raise_error + end + end + end + + describe "#validate_value!" do + context "with custom type option" do + let(:option_schema) { { is: user_class, message: nil } } + + it "accepts instance of custom type" do + user = user_class.new(name: "Alice", email: "alice@example.com") + expect { validator.validate_value!(user) }.not_to raise_error + end + + it "accepts nil (handled by RequiredValidator)" do + expect { validator.validate_value!(nil) }.not_to raise_error + end + + it "rejects instance of different class", :aggregate_failures do + admin = admin_class.new(name: "Bob", permissions: %w[read write]) + expect { validator.validate_value!(admin) }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("author") + expect(exception.message).to include("User") + # NOTE: For anonymous classes, actual class shows as # + # because Class#name returns nil for anonymous classes + end + ) + end + + it "rejects Hash", :aggregate_failures do + expect { validator.validate_value!({ name: "Alice" }) }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("author") + expect(exception.message).to include("User") + expect(exception.message).to include("Hash") + end + ) + end + + it "rejects String", :aggregate_failures do + expect { validator.validate_value!("Alice") }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("author") + expect(exception.message).to include("User") + expect(exception.message).to include("String") + end + ) + end + end + + context "with custom error message" do + let(:option_schema) { { is: user_class, message: "Must be a User instance" } } + + it "uses custom message on type mismatch" do + expect { validator.validate_value!({ name: "Alice" }) }.to( + raise_error(Treaty::Exceptions::Validation, "Must be a User instance") + ) + end + end + + context "with custom message as lambda" do + let(:option_schema) do + { + is: user_class, + message: ->(attribute:, actual:, **) { "#{attribute} requires User, got #{actual}" } + } + end + + it "evaluates lambda with context" do + expect { validator.validate_value!({ name: "Alice" }) }.to( + raise_error(Treaty::Exceptions::Validation, "author requires User, got Hash") + ) + end + end + + context "with anonymous class" do + let(:anonymous_class) { Class.new } + let(:option_schema) { { is: anonymous_class, message: nil } } + + it "accepts instance of anonymous class" do + instance = anonymous_class.new + expect { validator.validate_value!(instance) }.not_to raise_error + end + + it "rejects different type with meaningful error", :aggregate_failures do + expect { validator.validate_value!("test") }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("author") + end + ) + end + end + + context "with Struct as custom type" do + let(:user_struct) { Struct.new(:name, :email, keyword_init: true) } + let(:option_schema) { { is: user_struct, message: nil } } + + it "accepts Struct instance" do + user = user_struct.new(name: "Alice", email: "alice@example.com") + expect { validator.validate_value!(user) }.not_to raise_error + end + + it "rejects Hash" do + expect { validator.validate_value!({ name: "Alice" }) }.to raise_error(Treaty::Exceptions::Validation) + end + end + + context "with Data class as custom type (Ruby 3.2+)" do + let(:user_data) { Data.define(:name, :email) } + let(:option_schema) { { is: user_data, message: nil } } + + it "accepts Data instance" do + user = user_data.new(name: "Alice", email: "alice@example.com") + expect { validator.validate_value!(user) }.not_to raise_error + end + + it "rejects Hash" do + expect { validator.validate_value!({ name: "Alice" }) }.to raise_error(Treaty::Exceptions::Validation) + end + end + + context "without custom type option (default Hash)" do + let(:option_schema) { nil } + + it "accepts Hash" do + expect { validator.validate_value!({ name: "Alice" }) }.not_to raise_error + end + + it "rejects custom object", :aggregate_failures do + user = user_class.new(name: "Alice", email: "alice@example.com") + expect { validator.validate_value!(user) }.to( + raise_error(Treaty::Exceptions::Validation) do |exception| + expect(exception.message).to include("Hash") + end + ) + end + end + end + end end diff --git a/spec/treaty/entity/attribute/validation/concerns/conditional_processing_spec.rb b/spec/treaty/entity/attribute/validation/concerns/conditional_processing_spec.rb new file mode 100644 index 0000000..ef26591 --- /dev/null +++ b/spec/treaty/entity/attribute/validation/concerns/conditional_processing_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +RSpec.describe Treaty::Entity::Attribute::Validation::Concerns::ConditionalProcessing do + # Test class that includes the module + let(:test_class) do + Class.new do + include Treaty::Entity::Attribute::Validation::Concerns::ConditionalProcessing + + attr_reader :attribute + + def initialize(attribute) + @attribute = attribute + end + + # Expose private methods for testing + def public_conditional_option_for(nested_attribute) + conditional_option_for(nested_attribute) + end + + def public_should_process_attribute?(nested_attribute, source_data) + should_process_attribute?(nested_attribute, source_data) + end + + def public_conditionals_for_attributes + conditionals_for_attributes + end + end + end + + let(:parent_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :post, + collection_of_attributes: nested_attributes + ) + end + + let(:nested_attributes) { [] } + let(:processor) { test_class.new(parent_attribute) } + + describe "#conditional_option_for" do + context "with :if option" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: { if: ->(post:) { post[:status] == "published" } } + ) + end + + it "returns :if" do + expect(processor.public_conditional_option_for(nested_attribute)).to eq(:if) + end + end + + context "with :unless option" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: { unless: ->(post:) { post[:draft] } } + ) + end + + it "returns :unless" do + expect(processor.public_conditional_option_for(nested_attribute)).to eq(:unless) + end + end + + context "without conditional option" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: { required: { is: true, message: nil } } + ) + end + + it "returns nil" do + expect(processor.public_conditional_option_for(nested_attribute)).to be_nil + end + end + + context "with both :if and :unless options" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: { + if: ->(post:) { post[:status] == "published" }, + unless: ->(post:) { post[:draft] } + } + ) + end + + it "raises mutual exclusivity error" do + expect do + processor.public_conditional_option_for(nested_attribute) + end.to raise_error( + Treaty::Exceptions::Validation, + /title.*if.*unless/i + ) + end + end + end + + describe "#should_process_attribute?" do + context "without conditional option" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: {} + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { status: "published" } } + + it "returns true" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(true) + end + end + + context "with :if option that evaluates to true" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :analytics, + type: :object, + options: { if: ->(post:) { post[:status] == "published" } } + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { status: "published" } } + + it "returns true" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(true) + end + end + + context "with :if option that evaluates to false" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :analytics, + type: :object, + options: { if: ->(post:) { post[:status] == "published" } } + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { status: "draft" } } + + it "returns false" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(false) + end + end + + context "with :unless option that evaluates to false" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :password, + type: :string, + options: { unless: ->(post:) { post[:public] } } + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { public: false } } + + it "returns true (attribute should be processed)" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(true) + end + end + + context "with :unless option that evaluates to true" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :password, + type: :string, + options: { unless: ->(post:) { post[:public] } } + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { public: true } } + + it "returns false (attribute should be skipped)" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(false) + end + end + + context "when conditional evaluation raises error" do + let(:nested_attribute) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :broken, + type: :string, + options: { if: ->(**) { raise "Broken!" } } + ) + end + + let(:nested_attributes) { [nested_attribute] } + let(:source_data) { { status: "published" } } + + it "returns false (skip attribute on error)" do + expect(processor.public_should_process_attribute?(nested_attribute, source_data)).to be(false) + end + end + end + + describe "#conditionals_for_attributes" do + let(:nested_attribute_with_if) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :analytics, + type: :object, + options: { if: ->(post:) { post[:status] == "published" } } + ) + end + + let(:nested_attribute_without_conditional) do + instance_double( + Treaty::Entity::Attribute::Base, + name: :title, + type: :string, + options: {} + ) + end + + let(:nested_attributes) { [nested_attribute_with_if, nested_attribute_without_conditional] } + + it "caches conditional processors" do + first_call = processor.public_conditionals_for_attributes + second_call = processor.public_conditionals_for_attributes + + expect(first_call).to be(second_call) + end + + it "includes attributes with conditionals" do + result = processor.public_conditionals_for_attributes + + expect(result.keys).to include(nested_attribute_with_if) + end + + it "excludes attributes without conditionals" do + result = processor.public_conditionals_for_attributes + + expect(result.keys).not_to include(nested_attribute_without_conditional) + end + end +end diff --git a/spec/treaty/entity/attribute/validation/value_extractor_spec.rb b/spec/treaty/entity/attribute/validation/value_extractor_spec.rb new file mode 100644 index 0000000..bb30e0d --- /dev/null +++ b/spec/treaty/entity/attribute/validation/value_extractor_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +RSpec.describe Treaty::Entity::Attribute::Validation::ValueExtractor do + describe ".extract" do + context "with Hash source" do + let(:source) { { name: "Alice", email: "alice@example.com", age: 30 } } + + it "extracts value by symbol key" do + expect(described_class.extract(source, :name)).to eq("Alice") + end + + it "extracts nested value" do + nested_source = { user: { name: "Bob" } } + expect(described_class.extract(nested_source, :user)).to eq({ name: "Bob" }) + end + + it "returns nil for missing key" do + expect(described_class.extract(source, :unknown)).to be_nil + end + + it "returns nil for explicitly nil value" do + source_with_nil = { name: nil } + expect(described_class.extract(source_with_nil, :name)).to be_nil + end + + it "returns false for false value (not nil)" do + source_with_false = { active: false } + expect(described_class.extract(source_with_false, :active)).to be(false) + end + + it "returns empty string for empty string value" do + source_with_empty = { bio: "" } + expect(described_class.extract(source_with_empty, :bio)).to eq("") + end + end + + context "with Struct source" do + let(:user_struct) { Struct.new(:name, :email, keyword_init: true) } + let(:source) { user_struct.new(name: "Alice", email: "alice@example.com") } + + it "extracts value via public_send" do + expect(described_class.extract(source, :name)).to eq("Alice") + end + + it "extracts another attribute" do + expect(described_class.extract(source, :email)).to eq("alice@example.com") + end + + it "returns nil when method not defined" do + expect(described_class.extract(source, :unknown)).to be_nil + end + end + + context "with custom PORO class" do + let(:user_class) do + Class.new do + attr_reader :name, :email + + def initialize(name:, email:) + @name = name + @email = email + end + + private + + def secret + "hidden" + end + end + end + + let(:source) { user_class.new(name: "Alice", email: "alice@example.com") } + + it "extracts value via public_send" do + expect(described_class.extract(source, :name)).to eq("Alice") + end + + it "returns nil when method not defined" do + expect(described_class.extract(source, :unknown)).to be_nil + end + + it "returns nil for private methods" do + expect(described_class.extract(source, :secret)).to be_nil + end + end + + context "with Data class (Ruby 3.2+)" do + let(:user_data) { Data.define(:name, :email) } + let(:source) { user_data.new(name: "Alice", email: "alice@example.com") } + + it "extracts value via public_send" do + expect(described_class.extract(source, :name)).to eq("Alice") + end + + it "returns nil when method not defined" do + expect(described_class.extract(source, :unknown)).to be_nil + end + end + + context "with edge cases" do + it "handles nil source gracefully" do + expect(described_class.extract(nil, :name)).to be_nil + end + + it "handles empty Hash" do + expect(described_class.extract({}, :name)).to be_nil + end + + it "returns array values from Hash" do + source = { tags: %w[ruby rails] } + expect(described_class.extract(source, :tags)).to eq(%w[ruby rails]) + end + + it "returns hash values from Hash" do + source = { meta: { created_at: "2024-01-01" } } + expect(described_class.extract(source, :meta)).to eq({ created_at: "2024-01-01" }) + end + end + end +end