Schema composition

Hi dry-rb community! Forgive me if I’m asking a repeat question, but I’ve searched issues and forums and tried quite a bit of experimentation, but I’m still not able to solve my issue.

I’m using dry-schema / dry-validation in Hanami, but this question felt more applicable to dry-rb since it’s mostly not about the Hanami framework.

Here’s the summary:

  • I have a schema, nested under a top-level key.
  • I want to break the schema up into components (e.g. Name, MailingAddress, so I can use those components separately)
  • Then, I want to be able to compose those components back into this schema, nested under the top-level key

I can see how to compose schemas, and I can see how to nest — but I can’t figure out how to do both, and nothing I’ve tried has worked.

Here’s what I’d like to be able to do, where each of the classes is its own contract, with params, rules, and macros.

I think the first question is: is what I’m trying to do possible?

Thanks in advance for the help!

module MySlice
  module Contracts
    module AddressChangeRequest
      class Create < MySlice::Contract
        params do
          required(:change_request).hash(
            Name &
            Email &
            SocialSecurityNumber &
            MailingAddress
          )
        end
      end
    end
  end
end

Here’s the current state, which I’d like to break into components for the commented sections:

module Address
  module Contracts
    module ChangeRequest
      class Create < Address::Contract
        params do
          required(:change_request).hash do
            # Name
            required(:first_name).filled(:string)
            required(:last_name).filled(:string)
            # Email
            required(:email_address).filled(:string)
            # Social security number
            required(:social_security_number).filled(:string)
            # Mailing address
            required(:address_line_1).filled(:string)
            required(:address_line_2).maybe(:string)
            required(:city).filled(:string)
            required(:state).filled(:string)
            required(:zip).filled(:string)
          end
        end

        # Would be part of the SocialSecurityNumber contract
        # Rules for validating social security number characteristics
        rule(change_request: :social_security_number).validate(:ssn_format)
        rule(change_request: :social_security_number).validate(:ssn_zero_groups)
        rule(change_request: :social_security_number).validate(:ssn_area_range)

        # Would be part of the SocialSecurityNumber contract
        # Definition of rules for validating social security number characteristics
        register_macro(:ssn_format) do
          # omitted for brevity
        end
        register_macro(:ssn_zero_groups) do
          # omitted for brevity
        end
        register_macro(:ssn_area_range) do
          # omitted for brevity
        end

      end
    end
  end
end

Valid params for both of these should be structured like:

{
  change_request: {
    first_name: "Matt",
    last_name: "Cloyd",
    # ... and so on ...
  }
}

If I were you I’d define a ChangeRequest schema and nest it here, but you can also do it inline although it’s pretty janky-looking.

class Create < MySlice::Contract
  params do
    required(:change_request).hash(
      Dry::Schema.define(parent: [
        Name,
        Email,
        SocialSecurityNumber,
        MailingAddress
      ])
    )
  end
end

Thanks for the lead, @alassek!

In the interest of developing my knowledge here: what led you to think of the parent: key, and where is the documentation on that? I’ve been poking around dry-schema and dry-validation with little luck.

I’ve been fiddling with this, trying to find something that works — using :parent here gave me an error, but I found something that got me close.

require 'dry/validation'

class Name < Dry::Validation::Contract
  params do
    required(:first_name).filled(:string)
    required(:last_name).filled(:string)
  end
end

class Email < Dry::Validation::Contract
  params do
    required(:email_address).filled(:string)
  end
end

class CombinedParent < Dry::Validation::Contract
  params do
    required(:change_request).hash(
      Dry::Schema.define(parent: [Name, Email])
    )
  end
end
#=> ArgumentError: Parent configs differ, left=#<Class:0x000000012456d900>::Name, right=#<Class:0x000000012456d900>::Email   
# from /vendor/bundle/ruby/3.3.0/gems/dry-schema-1.13.4/lib/dry/schema/dsl.rb:504:in `block in default_config'


class CombinedSmall < Dry::Validation::Contract
  params do
    required(:change_request).hash(
      Name.schema & Email.schema 
    )
  end
end

CombinedSmall.new.call({
  change_request: { 
    first_name: "a",
    last_name: "z",
    email_address: "e"
  }
}).success?
#=> true

The problem I’m running into is, this only works for schemas, not validations, or rules. When I try to incorporate the SocialSecurityNumber module into this, which has macros and rules, those rules don’t get executed (because they’re not part of the schema) and I can’t figure out how to include them (calling SocialSecurityNumber.rules in the schema definition doesn’t work).

Any thoughts on composing validations here?

There is no documentation of that that I’m aware of, I know about it because I discover things about these frameworks by looking at the code.

Schemas and Contracts are different animals. You can’t use them interchangeably.

Think about Schemas as context-free rules; they validate a single key-value pair at a time.

Contracts are a layer above Schemas that run validation rules against the whole context.

The reason why your code doesn’t work is that you’re trying to make a schema inherit from a contract. You have to extract the inner schemas first.

class CombinedParent < Dry::Validation::Contract
  params do
    required(:change_request).hash(
      Dry::Schema.define(parent: [Name.schema, Email.schema])
    )
  end
end

The only officially supported way to make reusable contract rules is through macros.

There was some work done to make Contracts composable, but this was not completed. That thread does contain code for a macro that gets you close to what you want.