dry-rb / dry-matcher

Flexible, expressive pattern matching for Ruby

Home Page:https://dry-rb.org/gems/dry-matcher

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[RFC] Proposed flexibility improvements

baweaver opened this issue · comments

I've been reading through some of the Dry matcher code, and I had some ideas on how to potentially improve the syntax to be more succinct without losing any of the power it has.

This may meander a bit.

I'll reference, on occasion, two gems in this. One of which is my own implementation of pattern matching, Qo. The other is a minimalist gem, Any that matches any value and can be invaluable as a wildcard.

:ok and :err as synonyms for success and failure

In the examples, we frequently reference a success and failure case. Often, if not always, we mean them to be synonymous with :ok and :err conditions. What if this were baked into the API?

Consider the current implementation:

require "dry-matcher"

# Match `[:ok, some_value]` for success
success_case = Dry::Matcher::Case.new(
  match: -> value { value.first == :ok },
  resolve: -> value { value.last }
)

# Match `[:err, some_error_code, some_value]` for failure
failure_case = Dry::Matcher::Case.new(
  match: -> value, *pattern {
    value[0] == :err && (pattern.any? ? pattern.include?(value[1]) : true)
  },
  resolve: -> value { value.last }
)

# Build the matcher
matcher = Dry::Matcher.new(success: success_case, failure: failure_case)

If we were to make the two synonymous, it would eliminate the need for checking patterns on errors and allow us to treat it as just another case. In fact, it could likely eliminate the need to initialize them as separate objects altogether.

Breaking down cases

Consider that there are two components to a case: match and resolve. If we were to combine the two ideas, it would become a very similar use-case to Guard Block matchers I'd used in Qo, of which a minimal implementation may look like this:

MATCHED = true
NO_MATCH = false

def guard_block(*conditions, &guarded_function)
  -> target_value {
    if conditions.all? { |cond| cond === target_value }
      [MATCHED, guarded_function.call(target_value)]
    else
      [NO_MATCH, nil]
    end
  }
end

response = guard_block(:ok, Any) { |status, value| value }.call([:ok, 'Foo!'])
=> [false, nil]

A Guard Block will only call the block on the right if the value on the left "matches". This encompases both ideas, matching and resolving, in one mechanism. Utilizing Ruby blocks provides a more natural feeling syntax.

By treating success and failure as synonymous to :ok and :err we gain the ability to ignore the first param altogether. I'd have to think on how that implementation would look though.

Inline Matching

With the previous two items, it takes away the need to set up objects to define match criteria and resolution almost entirely.

That means you could likely take the final usecase here:

my_success = [:ok, "success!"]

result = matcher.(my_success) do |m|
  m.success do |v|
    "Yay: #{v}"
  end

  m.failure :not_found do |v|
    "Oops, not found: #{v}"
  end

  m.failure do |v|
    "Boo: #{v}"
  end
end

result # => "Yay: success!"

...and make it into the only thing you need to do a match:

my_success = [:ok, "success!"]

result = Dry::Matcher.match(my_success) do |m|
  m.success do |v|
    "Yay: #{v}"
  end

  m.failure :not_found do |v|
    "Oops, not found: #{v}"
  end

  m.failure do |v|
    "Boo: #{v}"
  end
end

result # => "Yay: success!"

Granted this would also imply a second paramater at least of type Any, and would require additional thinking.

There's a lot of power in implicits that yield massive wins in terms of succinctness and readability. That said, there are compromises to be made in flexibility as well to do such a thing.

The power of === and to_proc with type coercions

When you take the above items and combine them with the ability to use === to constitute a "matched" object, you can leverage Dry Monads as well as deconstructors like to_ary and to_hash.

Consider:

_, value = *Dry::Monads::Result::Success.new("success!")
# value = 'success!'

response_status = **Dry::Monads::Result::Success.new("success!")
# => { status: 'Success', value: 'success!' }

If we could get name punning (fingers crossed), we could get away with this:

[ _, my_value ] = Dry::Monads::Result::Success.new("success!")

{ value: my_value } = Dry::Monads::Result::Success.new("success!")
# => my_value = 'success!'

Now as far as to_proc, that makes for fun in inlining things:

responses.map(&Dry::Matcher.match(my_success) { |m|
  m.success { |v| "Yay: #{v}" }

  m.failure(:not_found) { |v| "Oops, not found: #{v}" }

  m.failure { |v| "Boo: #{v}" }
})

But that's all just musing

Yep, that it is. Just throwing around some ideas on how to improve the APIs potentially. The more succinct it is, the more likely it is to get adopted across Ruby projects.

I enjoy pattern matching immensely, and want to see some great implementations in Ruby. I'd be more than willing to help out in building whatever on the Dry projects, but for now I'll stick with RFCs to see what ideas are worth pursuing to the core maintainers.

Cheers!

  • baweaver

Hi @baweaver, thanks so much for taking the time to write these ideas.

I’m interested in seeing where we can take them. They certainly would make dry-matcher more usable as a “front line” tool in people’s applications.

On this point, I think it is worth noting that dry-matcher has never really been a front-line tool in and of itself. It started with fairly humble goals, built to serve a couple of purposes:

1.

To provide a nice interface for dealing with monadic result objects in places where you’re no longer wanting to work with the monad API directly. The best example of this is working with the results of functional operation objects in roda routes, where this:

r.resolve "article.operations.create" do |create_article|
  create_article.(r[:article]) do |m|
    m.success do |article|
      # this in fact creates a side effect, because the redirect method here issues a `throw`
      r.redirect "/admin/articles"
    end
    
    m.failure do |validation|
      r.view "articles.new", validation: validation
    end
  end
end

Feels a lot nicer than this (a more direct translation, but still not using the real monad API, and having to use awkwardly different accessors to fetch the value in either the success/failure cases):

r.resolve "article.operations.create" do |create_article|
  result = create_article.(r[:article])
  
  if result.success?
    # article = result.value
    r.redirect "/admin/articles"
  else
    r.view "articles.new", validation: result.failure
  end
end

Or this (using the monad API, but just getting awkward, in fact, I’m not 100% sure it’ll even work within roda routes):

r.resolve "article.operations.create" do |create_article|
  create_article.(r[:article])
    .fmap { |article|
      r.redirect "/admin/articles"
      # Need to pass something through so the chained `.or` will work
      ""
    }
    .or_fmap { |validation|
      flash.now[:alert] = "blah" # in real life we'd probably be doing even more side-effecty things here too, but maybe this is more an indictment of roda's API than anything else ¯\_(ツ)_/¯
      
      r.view "articles.new", validation: validation
    }.value! # at this point we have to force unwrap the value so that roda will know what to render (in this case the view HTML for the failure case)
end

2.

To support dry-transaction offering a compatible (but extended) result matching API:

class MyTransaction
  include Dry::Transaction(...)
  
  step :foo
  step :bar
end

my_trans = MyTransaction.new
my_trans.(input) do |m|
  m.success do |v|
    “Success stuff here”
  end
  
  m.failure :bar do |bar_err|
    # catch failures on “bar” step _only_
  end
  
  m.failure do |other_err|
    # catch failures on any other steps
  end
end

dry-transaction objects will of course return a dry-monads Result object, so you can also choose to use that API directly, but for the reasons I described above, sometimes this result matching API is a lot more natural.

(Sidenote: all the [:ok, blah], [:err, blah] stuff in the test suite was really just demonstrating theoretical use, and of course providing test coverage in a way that allowed me to keep dry-monads out of the tests as much as possible, just to keep things less complicated for those readings things over. I hope they didn’t give you the wrong idea about how the library’s been used so far.)

Anyway, those are the two primary use cases. And in both cases, they’re enabled by something else using dry-matcher, either dry-transaction, or e.g. an application operation base class which mixes in Dry::Matcher::ResultMatcher. When, as an application author, you’re using the matching API, you’re never actually dealing with dry-matcher directly.

What you’re proposing seems to be a couple of things:

  1. Make the API more fluent so it’s possible, even attractive, to work with dry-matcher directly at least in more cases than now
  2. (And still related to 1) build in a lot more assumptions/defaults/conventions etc. into some kind of OOTB API so it’s easier

I’m certainly very happy to see what we can do with (1). But what’s important, I think, is retaining the flexibility for different styles of matching to remain possible. This is why I took the approach of building up and configuring a matcher instance, because it means the library retains the ability to be used to provide matching behaviour I haven’t foreseen as the author developing it originally for those two specific use cases. Does that make sense? (FWIW, having some sort of decent out of the box behaviour for matching on result objects was important, which is why Dry::Matcher::ResultMatcher exists).

Anyway, after all of this, I’d be more than happy if you wanted to take some initial concrete steps towards improving things and we can see how we go from there? Your RFC post here covered a lot of things, so I’m not sure what the initial step would need to be. Perhaps the best next step would be for you to propose that?

Closing as we all just use pattern matching built into Ruby these days.