dry-schema: split string, validate elements against enum

Hi,

I get a parameter named includes as a comma-separated string, and want to validate that all its elements are within a set of allowed values. I believe, this is conceptually similar to this question, where an input string is coerced into an array of hashes.

I feel like I’m close, but something’s missing:

require "dry-schema"

module T
  include Dry.Types()

  CommaSepString = T.Constructor(Array) do |values|
    values.split(",")
  end
end

pp T::CommaSepString.("a,b,c")
#=> ["a", "b", "c"]

schema = Dry::Schema.Params do
  optional(:includes).value(T::CommaSepString, included_in?: %w[users favorites])
end

pp schema.("includes" => "users,favorites")
#=> #<Dry::Schema::Result{:includes=>["users", "favorites"]} errors={:includes=>["must be one of: users, favorites"]} path=[]>

I’ve also tried the array macro:

schema = Dry::Schema.Params do
  optional(:includes).array(T::CommaSepString, included_in?: %w[users favorites])
end

pp schema.("includes" => "users,favorites")
# #<Dry::Schema::Result{:includes=>"users,favorites"} errors={:includes=>["must be an array"]} path=[]>

Your problem is that you’re applying the constraint against the whole array rather than the individual elements.

Here how I solved a very similar problem

T = Dry.Types()

module T
  Inclusion = String.enum('users', 'favorites')

  module Params
    CSVList = T::Array.of(String).constructor do |value|
      if value.is_a?(::String)
        value.split(',')
      else
        Array(value)
      end
    end
  end
end


schema = Dry::Schema.Params do
  optional(:includes).value(T::Params::CSVList.of(T::Inclusion))
end
[2] pry(main)> schema.({ includes: 'users,favorites' })
=> #<Dry::Schema::Result{:includes=>["users", "favorites"]} errors={} path=[]>
[3] pry(main)> schema.({ includes: 'users,favorites,whatever' })
=> #<Dry::Schema::Result{:includes=>["users", "favorites", "whatever"]} errors={:includes=>{2=>["must be one of: users, favorites"]}} path=[]>
[4] pry(main)> 

That works perfectly, almost :slight_smile:

One thing that slipped my mind is, that I don’t use

T = Dry.Types()

but

T = Dry.Types(default: :params)
Full code sample
module T
  include Dry.Types(default: :params)

  CSVList = T::Array.of(String).constructor do |value|
    if value.is_a?(::String)
      value.split(',')
    else
      Array(value)
    end
  end
end

module Lists
  IncludeValues = T::String.enum('users', 'favorites')

  IncludeParams = Dry::Schema.Params do
    optional(:includes).value(T::CSVList.of IncludeValues)
  end
end

pp Lists::IncludeParams.('includes' => 'users,favorites')

This still yields an error:

#<Dry::Schema::Result{:includes=>"users,favorites"} errors={:includes=>["must be an array"]} path=[]>

It’s because T::Params is trying to do its coercion logic before the custom constructor runs. Types can have multiple constructors, and by default you’re adding to the end of the list.

You can work around this by prepending it

CSVList = T::Array.of(String).prepend do |value|
  if value.is_a?(::String)
    value.split(',')
  else
    Array(value)
  end
end

But I recommend using T::Strict::Array instead, which is more intention-revealing.

CSVList = T::Strict::Array.of(String).constructor do |value|
  if value.is_a?(::String)
    value.split(',')
  else
    Array(value)
  end
end

I would recommend not using include, even though this is how it is currently demonstrated on the website. There was a discussion about this somewhere.

The downside of include is that if you open your own Params module etc you will replace the original, whereas with this form:

T = Dry.Types(default: :params)

module T
  module Params
    CSVList = T::Array.of(String).prepend do |value|
      if value.is_a?(::String)
        value.split(',')
      else
        Array(value)
      end
    end
  end
end

Params reopens the Dry module. I do this because Strict, Params etc is useful information about how a type is intended to be used.

1 Like

Types can have multiple constructors, and by default you’re adding to the end of the list.

I wasn’t aware of that.

I would recommend not using include, even though this is how it is currently demonstrated on the website. There was a discussion about this somewhere.

The downside of include is that if you open your own Params module etc you will replace the original

Now that you’re mentioning it, I do have a Params module :slight_smile:

(For future readers: On this topic I found #422 (Params namespace disapearing) opened in 2021 and pr#432 (Update docs to recommend upfront module definition (i.e. Types = Dry.Types())) from January 2022.)

I recommend using T::Strict::Array

That has worked, thank you very much!

I should say, for completeness: this is not quite accurate. It’s not maintaining a list of constructors so much as composing them together.

So the difference between constructor and prepend is like f ∘ g vs g ∘ f