For predicates that don’t depend on injected dependencies I can use a custom predicates module (referred as “external” predicates in the source code) as described here. However, that technique cannot be used with injected dependencies. Apparently, dependencies are injected into the schema instance, but external predicates are not bound to the schema, so I get “undefined local variable or method <DEPENDENCY_NAME>” when my shared predicate tries to access the injected dependency:
module FailingPredicates
include Dry::Logic::Predicates
predicate(:color?) do |color|
colors.include?(color)
end
end
FailingSchema = Dry::Validation.Schema do
configure do
option :colors, %w[red green blue]
predicates(FailingPredicates)
end
required(:color).filled(:str?, :color?)
end
FailingSchema.(color: 'red') #=> undefined local variable or method `colors' for FailingPredicates:Module (NameError)
My first attempt to work around this limitation was to use regular mixin methods, as accessing injected dependencies worked just fine for predicates defined as methods on the schema instance:
module FailingPredicates
def color?(color)
colors.include?(color)
end
end
FailingSchema = Dry::Validation.Schema do
configure do
option :colors, %w[red green blue]
include FailingPredicates
end
required(:color).filled(:str?, :color?)
end
This workaround didn’t work either (raises “+color?+ is not a valid predicate name”). It turned out that defining custom predicates in the configure
block relies on the method_added
hook to register predicates.
So I’ve come up with the final workaround:
require 'bundler/inline'
gemfile(true) do
source 'https://rubygems.org'
gem 'dry-validation', '0.11.1'
gem 'rspec', require: 'rspec/autorun'
end
module SharedPredicates
def shared_option(name, default = nil)
__options[name] = default
end
def shared_predicate(name, &block)
__predicates[name] = block
end
def self.extended(mod)
mod.define_singleton_method(:included) do |base|
base.class_exec(__options, __predicates) do |options, predicates|
options.each { |(name, default)| option(name, default) }
predicates.each { |(name, block)| define_method(name, &block) }
end
end
end
private
def __options
@__options ||= {}
end
def __predicates
@__predicates ||= {}
end
end
module MyPredicates
extend SharedPredicates
shared_option :colors, %w[red green blue]
shared_predicate(:color?) do |value|
colors.include?(value)
end
end
Schema = Dry::Validation.Schema do
configure do
include MyPredicates
def self.messages
super.merge(
en: {
errors: { color?: 'Not a color: %{value}' }
}
)
end
end
required(:color).filled(:str?, :color?)
end
RSpec.describe Schema do
it 'works with default option values' do
expect(Schema.(color: 'red')).to be_success
expect(Schema.(color: 'magenta')).to be_failure
end
it 'works with injected option values' do
schema = Schema.with(colors: %w[cyan magenta yellow key])
expect(schema.(color: 'magenta')).to be_success
expect(schema.(color: 'red')).to be_failure
end
end
It seems to work, but looks kinda “hacky”, so I wonder if there is a better solution.