How do I use a macro with an optional property?

I ran into enough troubles attempting to use dry-validation 0.13 that I’m looking at 1.0. Based on my understanding, predicates are gone and built-in tools like gt? are gone. It appears that the replacements are macros.

I wrote something like this:

frozen_string_literal: true

require 'dry-validation'

class ApplicationContract < Dry::Validation::Contract
  register_macro(:my_macro) do
    raise "This should not be called"
  end
end

class TaskContract < ApplicationContract
  params do
    optional(:value_not_provided).value(:integer)
  end

  rule(:value_not_provided).validate(:my_macro)
end

p TaskContract.new.call({})

This fails as discussed in issue #540, which suggests that the rule body needs to have an if key? guard. However, the documentation doesn’t show how to use a macro except via Rule#validate.

I don’t see how to add the if key? test and use a macro. The closest I can see is to eschew using macros and just copy-paste the key.failure(…) if some_condition into every rule.

I’m obviously missing something obvious because I know that people aren’t inlining the definition of these basic reusable validations for each optional parameter.

This isn’t true, dry-schema still supports built-in predicates defined in dry-logic, including gt?. At the moment, we don’t have plans for removing them.

Technically, there’s little difference between macros and rule blocks in this regard. Macro’s body in your example will be called unconditionally so you need to add if key? inside it:

  register_macro(:my_macro) do
    raise "This should not be called" if key?
  end
1 Like

I guess it’s worth mentioning that predicates_as_macros extension will be introduced in the next version.

Unless I have an easy way to check if predicates hold lifting them up to the rule level won’t work well enough. For example, checking if an integer is a valid db identifier will require checking it’s within certain limits first but rules are fired independently so an invalid SQL query will be issued in any way.

Not sure if I follow

Lemme provide you with an extensive example:

require 'dry-validation'

module Types
  include Dry::Types()
end

class Database
  def exist?(id)
    unless (-2**63...2**63).include?(id)
      raise ArgumentError, 'Out of range!'
    end

    id == 123
  end
end

Dry::Validation.load_extensions(:predicates_as_macros)

class Contract < Dry::Validation::Contract
  option :db, default: -> { Database.new }

  import_predicates_as_macros

  register_macro(:check_user_id) do
    key.failure('does not exist') unless db.exist?(value)
  end
end

class PredicateCheck < Contract
  json do
    required(:user_id).value(:integer, lt?: 2**63, gteq?: -2**63)
  end

  rule(:user_id).validate(:check_user_id)
end

class MacroCheck < Contract
  json do
    required(:user_id).value(:integer)
  end

  rule(:user_id).validate(:check_user_id, lt?: 2**63, gteq?: -2**63)
end

def test(contract)
  p contract.(user_id: 234)
  p contract.(user_id: 123)
  p contract.(user_id: 2**65)
  nil
end

Now it works perfectly with type checks:

dry-validation> test(PredicateCheck.new)
#<Dry::Validation::Result{:user_id=>234} errors={:user_id=>["does not exist"]}>
#<Dry::Validation::Result{:user_id=>123} errors={}>
#<Dry::Validation::Result{:user_id=>36893488147419103232} errors={:user_id=>["must be less than 9223372036854775808"]}>
=> nil

And blows up with macros:

dry-validation> test(MacroCheck.new)
#<Dry::Validation::Result{:user_id=>234} errors={:user_id=>["does not exist"]}>
#<Dry::Validation::Result{:user_id=>123} errors={}>
ArgumentError: Out of range!
from ./bin/console:16:in `exist?'

Gotcha. Rules depending on other rules will be supported too at some point.

Is there a way to create custom predicates? I haven’t seen such, suggesting that rules/macros are the replacement.

Also, you state “supported”, but is it encouraged to use predicates?

This seems to mean that it’s harder to compose macros. I’ll have to define one for required values and one for optional values. I’m not sure how to call one macro from another macro, so right now this would entail duplication:

register_macro(:my_macro) do
  raise "This should not be called"
end

register_macro(:my_macro_optional) do
  raise "This should not be called" if key?
end

The problem is deeper than a simple yes/no answer. dry-logic provides basic facilities for constructing types. It’s not related to validation but describes well-defined values of your domain or application. Things like Status = Types::String.enum('created', 'posted', 'deleted') is an example of domain type, PosInteger = Types::Integer.constrained(gt: 0) is an example of application type. You can use them in your schema, this is reasonable. What is not reasonable is using predicates for domain-specific checks. For example, validating age with a type doesn’t make sense in general because even if you require a user to be older than 17 Types::Age still is a zero-or-greater integer, not a greater-than-17-integer. OTOH, you may want to make ages under 17 unrepresentable in your app and use such type everywhere, this is a point for arguing. At the end of the day, it’s up to you but you should remember types can’t provide arbitrary checks, they are limited.

I don’t think I understand what you want to achieve and whether the example is real or not.

The example with raise is silly indeed, but let’s take the one from the documentation and expand it a bit:

# frozen_string_literal: true

require 'dry-validation'

Dry::Validation.register_macro(:email_format) do
  unless /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i.match?(value)
    key.failure('not a valid email format')
  end
end

class NewUserContract < Dry::Validation::Contract
  params do
    required(:primary_email).filled(:string)
    optional(:secondary_email).filled(:string)
  end

  rule(:primary_email).validate(:email_format)
  rule(:secondary_email).validate(:email_format)
end

puts NewUserContract.new.call(primary_email: 'a@example.com').inspect
#<Dry::Validation::Result{:primary_email=>"a@example.com"} errors={:secondary_email=>["not a valid email format"]}>

How do I use the :email_format macro for :secondary_email?

The direct solution I see is to add if key?, but adding if key? to every macro seems redundant.

Another option would be if there’s some syntax to allow conditionally applying a macro:

# This is made-up syntax and doesn’t work
rule(:secondary_email).validate do
  :email_format if key?
end

It seems as if you are arguing against yourself here; am I reading that correctly?

While i’m in love with static type systems and the guarantees that they can provide, I recognize that Ruby isn’t a statically typed language and the idioms for one language are different for another. In Ruby, we see a lot more laissez-faire treatment of types and it’s rarer to see little wrapper types to provide type safety.

Can you expand a bit on what you mean by “limited”?

This is discussed in the issue you referenced initially. Until a shortcut like rule? is added next if key? is the recommended way of checking if a key is present.

Making invalid values unrepresentable is a technique, it’s not a replacement for validation, merely a tool. You can use it in combination with schemas. What arguable here is whether this is worth the effort.

Whoa! Where did next come from? Searching on the issue I linked for next has no results. I’ve only seen variants of nesting the key.failure inside of if key?. I did actually try return unless key? but that’s not valid in that context.

That being said, that’s answering a slightly different question, and I suppose I should have been clearer.

I have a macro that was created assuming the value is present (e.g. :email_format), but I want to use it in an optional context. I literally do not know how to use a macro other than via Rule#validate, but the documentation only shows passing a symbol to validate, so I don’t know how to combine calling an existing macro with the key? check.

Are you stating that there’s no other solution than to edit the macro and add if key? to it?

It simply will not work. You cannot have an arbitrary macro called in an arbitrary context.

Pretty much, but what’s the problem with this? It’s still a single macro, the only difference it can work for both optional and required cases.

Nothing special, it comes from ruby :slight_smile:
https://ruby-doc.org/core-2.6.3/doc/syntax/control_expressions_rdoc.html#label-next+Statement

Right, I’m aware of the Ruby keyword next, but you indicated that I had not done my due diligence before asking the question:

I don’t see where it occurs in the issue. It’s also not mentioned in the macro docs. That’s why I’m asking where it came from.

I honestly don’t know the behavior of using next in an arbitrary block; I think I’ve only ever used it explicitly in iteration, which is also how the docs you’ve linked describe it.

I don’t wish to call it in an arbitrary context, and I don’t know what I’ve said that makes you think that’s my intent. If I want to call a macro as part of a rule, I can do that with Rule#validate. If I want to conditionally call a rule as part of a rule, it’s evidently currently impossible. That’s a rough functionality cliff to fall off of.

I don’t know that I’ll be able to effectively communicate the benefits of being able to compose multiple smaller pieces.

Perhaps another direction to approach this is: under what circumstances would you ever want to not start a macro with next unless key??

A little bit of a stretch to my taste but I see your point. I’m not saying it’s not worth adding or something like this, I’m trying to say you can have a workaround for the time being if you have an issue right now. If you don’t, that’s fine, the feature will eventually be added, you can send a PR if you like.

Ok, I don’t know about your case but I can say about mine. In my project, I have 4 macros and 40 contracts so far (and many more schemas without contracts). All 4 macros have preconditions and all of them are different. Only one is next unless key? but two are next unless value, fourth is different. key? and value clearly have different semantics so they cannot be generalized with one check. Adding rule? would save me exactly one line of code.