dry-rb/dry-validation

Failed to coerce a property when at least one predicates in a nested hash do not meet validation rules.

Open

#741 opened on May 29, 2025

View on GitHub
 (1 comment) (0 reactions) (0 assignees)Ruby (193 forks)github user discovery
bughelp wanted

Repository metrics

Stars
 (1,421 stars)
PR merge metrics
 (PR metrics pending)

Description

Describe the bug

When dealing with predicates that are nested within a hash, either they all succeed or all fail.

To Reproduce

# frozen_string_literal: true

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'dry-validation'
  gem 'rspec'
end

require 'rspec/autorun'

class RecurringTransferContract < Dry::Validation::Contract
  DATE_ISO8601_REGEX = /\A\d{4}-\d{2}-\d{2}\z/

  params do
    required(:recurring_transfer).hash do
      required(:first_execution_date).filter(format?: DATE_ISO8601_REGEX).value(:date)
      optional(:last_execution_date).filter(format?: DATE_ISO8601_REGEX).value(:date)
    end
  end

  rule(recurring_transfer: :first_execution_date) do
    key.failure({code: "i_am_a_string", text: "It is a string instead of a date"}) if value.is_a?(String)
  end

  RSpec.describe RecurringTransferContract do
    it "succeeds when both dates are valid" do
      contract = described_class.new.call(recurring_transfer: {first_execution_date: "2025-01-01", last_execution_date: "2025-11-03"})

      expect(contract).to be_success
    end

    it "raises an error when last_execution_date is not passing the filter predicate" do
      contract = described_class.new.call(recurring_transfer: {first_execution_date: "2025-01-01", last_execution_date: ""})

      expect(contract).to be_failure
      expect(contract.errors.to_h[:recurring_transfer]).to eq(
        {
          first_execution_date: [{code: "i_am_a_string", text: "It is a string instead of a date"}],
          last_execution_date: ["is in invalid format"],
        }
      )
    end
  end
end

Running the test above fails because of first_execution_date: [{text: "It is a string instead of a date", code: "i_am_a_string"}]

Expected behavior

My expectation would be that first_execution_date is always casted to a Date since it is passing the filter predicate.

However, if you you move the predicates to the top level, it works as expected

# frozen_string_literal: true

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'dry-validation'
  gem 'rspec'
end

require 'rspec/autorun'

class RecurringTransferContract < Dry::Validation::Contract
  DATE_ISO8601_REGEX = /\A\d{4}-\d{2}-\d{2}\z/

  params do
    required(:first_execution_date).filter(format?: DATE_ISO8601_REGEX).value(:date)
    optional(:last_execution_date).filter(format?: DATE_ISO8601_REGEX).value(:date)
  end

  rule(:first_execution_date) do
    key.failure({code: "i_am_a_string", text: "It is a string instead of a date"}) if value.is_a?(String)
  end

  RSpec.describe RecurringTransferContract do
    it "succeeds when both dates are valid" do
      contract = described_class.new.call({first_execution_date: "2025-01-01", last_execution_date: "2025-11-03"})

      expect(contract).to be_success
    end

    it "raises an error when last_execution_date is not passing the filter predicate" do
      contract = described_class.new.call({first_execution_date: "2025-01-01", last_execution_date: ""})

      expect(contract).to be_failure
      expect(contract.errors.to_h).to eq(
        {
          last_execution_date: ["is in invalid format"]
        }
      )
    end
  end
end

My environment

  • Affects my production application: YES
  • Ruby version: ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]
  • OS: MacOS

Contributor guide