Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

No obvious way to use nullary predicates in constraints #473

Closed
dcr8898 opened this issue Jul 3, 2024 · 8 comments · Fixed by #474
Closed

No obvious way to use nullary predicates in constraints #473

dcr8898 opened this issue Jul 3, 2024 · 8 comments · Fixed by #474

Comments

@dcr8898
Copy link
Contributor

dcr8898 commented Jul 3, 2024

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 dcr8898 changed the title No obvious way to use nullary predicates with constraints No obvious way to use nullary predicates in constraints Jul 3, 2024
@dcr8898
Copy link
Contributor Author

dcr8898 commented Jul 6, 2024

@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
Copy link
Contributor Author

dcr8898 commented Jul 6, 2024

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
Copy link
Member

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
Copy link
Member

solnic commented Jul 8, 2024

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
Copy link
Contributor Author

dcr8898 commented Jul 9, 2024

@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!
  • Shall I implement .flatten and add new tests for the various use cases?
  • Do you want the tests to remain in predicate_inferrer_spec?
  • Do you want to support passing an actual Hash of options?
  • Do you want to implement the same change to Builder#constrained?

Thank you!

@dcr8898
Copy link
Contributor Author

dcr8898 commented Jul 15, 2024

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

@flash-gordon
Copy link
Member

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

@dcr8898
Copy link
Contributor Author

dcr8898 commented Jul 15, 2024

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. 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants