No (straightforward) way to reuse predicates which use injected dependencies


#1

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.