dry-rb / dry-types

Flexible type system for Ruby with coercions and constraints
https://dry-rb.org/gems/dry-types
MIT License
860 stars 134 forks source link

No obvious way to use nullary predicates in constraints #473

Closed dcr8898 closed 4 months ago

dcr8898 commented 4 months ago

Describe the bug

The documentation states that "Under the hood dry-types uses dry-logic and all of its predicates are supported." However, there is no obvious way to use nullary predicates (predicates that don't take an argument--that is, arity = 0), like odd?.

I previously raised this issue with the community.

To Reproduce

odd = Types::Integer.constrained(:odd)

NoMethodError: undefined method 'map' for :odd:Symbol
from /home/damian/.gem/ruby/3.2.2/gems/dry-types-1.7.2/lib/dry/types/contraints.rb:15:in 'Rule'

Expected behavior

There should be a conventional way to pass nullary predicates.

Workarounds

After reviewing the Rule class method in /lib/dry/types/constraints.rb, I see that it expects to receive only keyword arguments that it can treat as a Hash and map over. This implied a workaround for using nullary predicates by including a dummy value.

odd = Types::Integer.constrained(odd: nil)
=> #<Dry::Types[Constrained<Nominal<Integer> rule=[type?(Integer) AND odd?]>]>

@flash-gordon pointed out that you can achieve the same workaround by passing an array of one or more nullary predicates.

odd = Types::Integer.constrained([:odd])
=> #<Dry::Types[Constrained<Nominal<Integer> rule=[type?(Integer) AND odd?]>]>

However, this workaround does not allow users to combine nullary predicates with unary predicates (that take one argument), as can be done by explicitly passing dummy values.

positively_odd = Types::Integer.constrained(odd: nil, gt: 0)
=> #<Dry::Types[Constrained<Nominal<Integer> rule=[type?(Integer) AND odd? AND gt?(0)]>]>

Offer

I am willing to submit a pull request to correct this issue, but I don't want to do so without a conversation on implementation.

It seems to me that one way to handle this is to require nullary predicates to be grouped first, before the unary predicates that are passed as keyword arguments. This would allow us to glom the nullary predicates into an array of positional arguments, map this to a Hash with nil dummy values, and merge with the keyword argument Hash before processing as currently implemented.

If that sounds good, I'm happy to code it up. In addition, I'm willing to update the docsite with more discussion and examples of using dry-logic predicates as constraints in dry-types. (Partially addressing #390)

Thank You

Thank you!

dcr8898 commented 4 months ago

@flash-gordon, I have implemented the strategy that I described above. It works, but it breaks some specs for PredicateInferrer.

I can see from the PredicateInferrer specs that your established method for calling nullary predicates is to wrap them in an array, and that you combine them with unary predicates by chaining another constrained method. These specs could be changed to use the proposed method signature instead of an array, but the existence of these specs implies that the proposed solution is a breaking change (even if the broken behavior is undocumented outside of the specs).

The proposed solution also breaks the spec for constraints defined on optional types (constrained_spec.rb:226). This is fixable by double-splatting the options argument passed to .constrained in sum.rb:89. This doesn't seem to break any other tests.

I don't want to go any further unless I know this is a desired path. I think the proposed solution is more intuitive than using an array and multiple .constrained methods (or passing dummy arguments), but if it breaks existing code then it would have to be introduced appropriately.

dcr8898 commented 4 months ago

You can see the proposed change with tests here.

I just noticed that Builder also implements a constrained method. I presume this would have to be changed in the same way for consistency.

flash-gordon commented 4 months ago

Before moving further I'd like to describe the assumed changes in the API.

In the new version, all these will be acceptable:

type.constrained(:odd) # new variant
type.constrained(:odd, :even) # new variant
type.constrained(:odd, gt: 200, lt: 300) # new variant
type.constrained([:odd, :even]) # worked before, continue to support
type.constrained([:odd, :even], gt: 200, lt: 300) # new variant, supported as a side-effect
type.constrained # error, as before
type.constrained([]) # worked before, continue to support
type.constrained({}) # worked before, continue to support

It's a minor issue but the new API will allocate slightly more objects because of * and **.

@solnic any arguments against this change? It's backwards-compatible it seems

solnic commented 4 months ago

Yes this is a good addition. Types should be memoized anyway (in typical scenarios at least) so I wouldn't worry about increased object allocs.

dcr8898 commented 4 months ago

@flash-gordon Thank you for providing a formal specification.

These work with the current proposed implementation:

type.constrained(:odd) # new variant
type.constrained(:odd, :even) # new variant
type.constrained(:odd, gt: 200, lt: 300) # new variant

type.constrained # error, as before

These do not work with the currently proposed change, but could be implemented by adding .flatten to the processing of the positional arguments.

type.constrained([:odd, :even]) # worked before, continue to support
type.constrained([:odd, :even], gt: 200, lt: 300) # new variant, supported as a side-effect

type.constrained([]) # worked before, continue to support

The following use cases, the last of which was not mentioned above, will only work if we add an explicit Hash check to the positional arguments.

type.constrained({}) # worked before, continue to support

type.constrained({gt: 200, lt: 300}) # worked before, will no longer work!

Thank you!

dcr8898 commented 4 months ago

I have submitted a PR (#474) to resolve this. Includes tests and yardocs. Some issues remain. See the PR for details.

flash-gordon commented 4 months ago

@dcr8898 yeah, thanks, I'll take a look soon

dcr8898 commented 4 months ago

That's cool. Take your time. I just thought it would be easier for you to look at code instead of dealing with my questions. 😁