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

Support matchers with custom match conditions

timriley opened this issue · comments

n.b. See #3 (comment) for the most up-to-date thinking about this issue


We want something like this:

result = Left(:special_failure)

Dry::ResultMatcher.match(result) do |m|
  m.success do
    # ...
  end

  m.failure(:special_failure) do
    # this will only run if the result is ":special_failure"
  end

  m.failure(:other_kind_of_failure) do
    # this will not match
  end

  m.failure do
    # TBD whether this should match
  end
end

I think we want to make the failure matching configurable, so this can be used in different contexts (e.g. dry-transaction would set up failure matching for its own StepFailure objects). This would require us to make a new matcher object with this configuration passed to it.

The other thing that's worth considering here is how we handle multiple failure matches. The way I see it, we have 2 options:

  1. Match on any block that matches (if their match parameter matches, or by default if no specific match parameter is provided). Run the match blocks in the order that they were specified.
  2. Match on the first block only and ignore any subsequent matches.

The reason why these are mutually exclusive options right now is because the match block is currently meant to return a significant value. If we allow multiple matches, the behaviour around what to return becomes less clear. A significant return value has been important since our usage so far has been within Roda routing blocks, and we need to return the view's HTML string in success blocks. However, it feels like this imposes too much of a limitation on our match behaviour (especially when I've just learnt we could use the halt plugin to render).

I think we could probably just pick a simple rule for the return value (last matching block's return value wins) and leave it at that. This'd let us support running multiple match blocks (option 1 above).

@timriley I've started to play with this concept as it's something I need on my app.

The main "problem" right now is where to define the matcher condition. Your example result = Left(:special_failure) is a bit too simple, usually a failure would return some kind of value too, a failed validation would return the errors or something like that.

So, having Left(errors: errors, on: :validation) doesn't seem correct, this would mess up the Monad API. And the ResultMatcher would have to know about how to access on from it.

Not sure if I understood you right, but we could create a new Match object that would encapsulate the Monad with the matching options, the problem with this is that plain Monads wouldn't work out of the box with matcher conditions. Ex: Match(:unprocessable, Left(errors)).
This is easy to work with in dry-transactions and such, but would be a bit cumbersome for Dry::ResultMatcher.for(:call) API, as the call method would have to return a Match object if it wanted to use conditional matchers.

Another simple option I was trying out is initializing Dry::ResultMatcher::Matcher with: .new(Left(errors), match_on: :unprocessable), it works when using the Matcher directly, but again the problem with this approach is also when trying to use the .for(:method API.

Another option is to support basic Monads without conditional matcher, they would just use the basic success or failure blocks, when conditional matchers are needed we would require an extended API.

What are you thoughts on this?

@mrbongiolo I implemented this in rodakase (now dry-web) on top of kleisli so I would expect it to be doable with dry-monads: https://github.com/dry-rb/dry-web/blob/v0.0.1/spec/integration/transaction_spec.rb

@solnic thanks for the info. In that case you've wrapped the monad on a Transaction, we might need to do something similar here, since the monad itself doesn't care about a failure code or name. This might be the best option after all, let the ResultMatcher accept bare Monads or wrapped Monads in a Match.

Hi @mrbongiolo, thanks for your thinking about this! You’re right in that my example was too simplistic.

What I think we want here is to be able to instantiate a matcher object and configure it with the following things:

  • A list of “match types”, so e.g. :success and :failure to match our current behaviour
  • And for each match type:
    • A callable object (e.g. proc/block or otherwise) to call with (a) a provided match argument, and (b) the result object, returning true or false if a match is found
    • A callable object to prepare the matched result value before it gets passed to the match block

Then that matcher object would be the thing we use to match on the result and run

With a structure like this, we’d want this to match the current (hard-coded) behaviour:

    # For :success match type...
    # matcher fn:
    -> pattern, result { result.right? }
    # value preparation fn:
    -> result { result.value }

    # For :failure match type...
    # matcher fn:
    -> pattern, result { result.left? }
    # value preparation fn:
    -> result { result.value }

With that in place, we could start to do some interesting things. For example, instead of how dry-transaction right now has a StepFailure object that wraps the actual failure value, we could choose to return our failures as a Left monad with an array of two objects: e.g. Left([:my_failure_type, actual_value]). We could work with this objects like so:

    # matcher fn:
    -> pattern, result {
      result.right? && result.value.first == pattern
    }

    # vaue preparation fn:
    -> result { result.value.last }

I haven’t thought too much about the API for actually assembling a match object with this kind of configuration yet. But very roughly we’d end up with something like this:

    # TODO: work out how `configuration` is actually provided
    my_matcher = Dry::ResultMatcher.new(configuration)

    failure = Dry::Monads::Either::Left([:my_failure, "bzzt"])

    my_matcher.match(failure) do |m|
      m.failure :my_failure do |v|
        puts "Failed with a my_failure! #{value}"
      end
    end

This should result in “Failed with a my_failure! bzzt” being printed.

By being able to specify your match types, and then callable matchers and value preparation objects for each type, we should have the flexibility to use the dry-result_matcher API with any sort of data, in any sort of context! It’d probably be helpful for us to provide some default matcher configurations to use out of the box, too :)

How does this sound to you? Would it satisfy your requirements for working with the matcher and your own result objects?

Thinking something like this should work for an API:

success_case = Dry::ResultMatcher::Case.new(
  match: -> pattern, result { result.right? },
  resolve: -> result { result.vaue },
)

failure_case = Dry::ResultMatcher::Case.new(
  match: -> pattern, result { result.left? },
  resolve: -> result { result.vaue },
)

matcher = Dry::ResultMatcher.new(
  success: success_case,
  failure: failure_case,
)

# `matcher` would then support blocks like this:
#
# matcher.match do |m|
#   m.success do
#   end
#
#   m.failure do
#   end
# end

We could think about building a higher-level API or DSL on top of this later on, but I don't think we need to rush into that.

Seem OK? If yes, I might start putting something together this week.

Hmm this makes me think it should be renamed to dry-matcher :) The API looks sweet, although I'd do match.call instead of match since it would be a low-lvl thing and a top-lvl thing could have #match and use the matcher object under the hood #quickthoughts

@timriley that seems Ok! You would use those success and failure as builtin cases for dry-matcher? Then with that API we could have more complex cases for dry-transaction or anything else.

@mrbongiolo Cool, glad this is looking positive for you.

So the idea for out-of-the-box usage (i.e. a match for the current version) would be to use the bundled EitherMatcher object:

require "dry/result_matcher/either_matcher"

# For class enhancement
class MyClass
  include Dry::ResultMatcher.for(:call, with: Dry::ResultMatcher::EitherMatcher)

  def call
    Dry::Monads::Right("hi")
  end
end

# Or for direct usage
Dry::ResultMatcher::EitherMatcher.call(Right("hi")) do |m|
  m.success do |v|
    "Success: #{v}"
  end

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

This leaves the rest of the library focused on supplying the tools for building your own matchers, which is what we'd do in dry-transaction, yes: build a matcher that supports matching on the various step failures.