Hello everyone,
I’m currently using dry-types
version 1.7.2 in a large project, and I need to update a custom type to work with a dynamic list for the included_in
constraint without breaking existing tests.
Previously, I had:
Currency = String.constrained(
included_in: Money::Currency.all.map(&:iso_code)
)
I changed it to:
Currency = Types::String.constructor do |value|
iso_codes = Money::Currency.all.map(&:iso_code)
if iso_codes.include?(value)
value
else
list = iso_codes.map { |iso_code| %["#{iso_code}"] }.join(', ')
err_message = %["#{value}" violates constraints (included_in?([#{list}], "#{value}") failed)]
raise Dry::Types::ConstraintError.new(value, err_message)
end
end
Could someone please help me update the Currency
constructor so that it behaves exactly like included_in
, ensuring that all tests in the project continue to pass?
I think I’m raising the error the wrong way
Am I correct in assuming that you are attempting to late-bind this list, so that Money::Currency.all
can be populated at startup?
If so, you’re overthinking it:
Currency = Types::String.constructor do |value, type|
type.constrained(included_in: Money::Currency.all.map(&:iso_code)).call(value)
end
1 Like
At validation moment to be correct
But anyway, the code you provided works perfectly! You’re awesome, thank you a lot!
@alassek one more thing - is there a way to make the behavior exactly the same?
module NewTypes
include Dry.Types()
CURRENCIES = %w[EUR USD]
Currency1 = String.constrained(
included_in: CURRENCIES
)
Currency2 = String.constructor do |value, type|
type.constrained(included_in: CURRENCIES).call(value)
end
end
CurrencyParams1 = Dry::Schema.Params do
required(:currency).filled(::NewTypes::Currency1)
end
CurrencyParams2 = Dry::Schema.Params do
required(:currency).filled(::NewTypes::Currency2)
end
I want to keep this behaviour (to keep tests in project continue to pass):
[3] pry(main)> CurrencyParams1.call(currency: 'INVALID')
=> #<Dry::Schema::Result{:currency=>"INVALID"} errors={:currency=>["must be one of: EUR, USD"]} path=[]>
But I’m getting this:
[4] pry(main)> CurrencyParams2.call(currency: 'INVALID')
Dry::Types::ConstraintError: "INVALID" violates constraints (included_in?(["EUR", "USD"], "INVALID") failed)
from /bundle/ruby/3.2.0/gems/dry-types-1.7.2/lib/dry/types/constrained.rb:37:in `call_unsafe'
It does not appear that Dry::Schema
will work with that approach.
I’m going to show you the correct way to do this, and then the quick-and-dirty way.
Types are not meant to be mutable at runtime. If you need runtime data to alter the behavior of your validations, then a type object is the wrong level of abstraction for this.
The correct place to do this in a dry-validator contract. It is specifically built to allow for runtime dependencies.
class MoneyField < Dry::Validation::Contract
option :iso_codes, Types::Array.of('string')
params do
required(:currency).filled
end
rule :currency do
unless iso_codes.include?(value)
key.failure("must be one of: #{iso_codes.join(', ')}")
end
end
end
contract = MoneyField.new(
iso_codes: Money::Currency.all.map(&:iso_code)
)
contract.call(currency: "EUR")
There is a bad way to do this. The object passed to :included_in
is stored by reference, and can be mutable.
module AllowedCurrencies
ISO_CODES = []
def self.setup(codes)
ISO_CODES.clear.concat(codes)
end
end
Types = Dry.Types(default: :strict)
module Types
Currency = String.constrained(included_in: AllowedCurrencies::ISO_CODES)
end
# Somewhere in your startup routine
Money::Currency.all.map(&:iso_code).then do |codes|
AllowedCurrencies.setup(codes)
end
Since this approach relies on globally-mutable state, I would advise against this.
This is a situation where dry-auto_inject shines.
module Container
extend Dry::Core::Container::Mixin
register "currency.iso_codes" do
Money::Currency.all.map(&:iso_code)
end
end
Deps = Dry::AutoInject(Container)
class MoneyField < Dry::Validation::Contract
include Deps["currency.iso_codes"]
params do
required(:currency).filled
end
rule :currency do
unless iso_codes.include?(value)
key.failure("must be one of: #{iso_codes.join(', ')}")
end
end
end
MoneyField.new.call(currency: "USD")
@alassek Thank you, man! How can I tip you to show my appreciation?