Giter Club home page Giter Club logo

qo's Introduction

Qo

Build Status Maintainability Gem Version

Short for Query Object, my play at Ruby pattern matching and fluent querying, pronounced "Q-whoah".

Qo Lemur logo

Read the Docs for more detailed information

How does it work?

Mostly by using Ruby language features like to_proc and ===.

There's an article explaining most of the base mechanics behind Qo:

For Want of Pattern Matching in Ruby - The Creation of Qo

Most of it, though, utilizes Triple Equals. If you're not familiar with what all you can do with it in Ruby, I would encourage you to read this article as well:

Triple Equals Black Magic

The original inspiration was from a chat I'd had with a few other Rubyists about pattern matching, which led to this experiment:

Having fun with M and Q

Fast forward a few months and I kind of wanted to make it real, so here it is. Introducing Qo!

Usage

Note that Qo uses the Any gem for wildcard matching. Any will respond true to any == or === query against it, and is included in the gem.

Quick Start

Qo is used for pattern matching in Ruby. All Qo matchers respond to === and to_proc meaning they can be used with case and Enumerable functions alike:

case ['Foo', 42]
when Qo[Any, 42] then 'Truly the one answer'
else nil
end

# Run a select like an AR query, getting the age attribute against a range
people.select(&Qo[age: 18..30])

How about some pattern matching? There are two styles:

Pattern Match

Case Statements

Qo case statements work much like a Ruby case statement, except in that they leverage the full power of Qo matchers behind the scenes.

# How about some "right-hand assignment" pattern matching
name_longer_than_three = -> person { person.name.size > 3 }

person_with_truncated_name = Qo.case(people.first) { |m|
  m.when(name_longer_than_three) { |person|
    Person.new(person.name[0..2], person.age)
  }

  m.else
}

It takes in a value directly, and returns the result, much like a case statement.

Note that if else receives no block, it will default to an identity function ({ |v| v }). If no else is provided and there's no match, you'll get back a nil. You can write this out if you wish.

Match Statements

Match statements are like case statements, except in that they don't directly take a value to match against. They're waiting for a value to come in later from something else.

name_longer_than_three = -> person { person.name.size > 3 }

people_with_truncated_names = people.map(&Qo.match { |m|
  m.when(name_longer_than_three) { |person| Person.new(person.name[0..2], person.age) }
  m.else
})

# And standalone like a case:
Qo.match { |m|
  m.when(age: 10..19) { |person| "#{person.name} is a teen that's #{person.age} years old" }
  m.else { |person| "#{person.name} is #{person.age} years old" }
}.call(people.first)

Qo'isms

Qo supports three main types of queries: and, or, and not.

Most examples are written in terms of and and its alias []. [] is mostly used for portable syntax:

Qo[/Rob/, 22]

# ...is functionally the same as an and query, which uses `all?` to match
Qo.and(/Rob/, 22)

# This is shorthand for
Qo::Matchers::BaseMatcher.new('and', /Rob/, 22)

# An `or` matcher uses the same shorthand as `and` but uses `any?` behind the scenes instead:
Qo.or(/Rob/, 22)

# Same with not, except it uses `none?`
Qo.not(/Rob/, 22)

Qo has a few Qo'isms, mainly based around triple equals in Ruby. See the above articles for tutorials on that count.

We will assume the following data:

people_arrays = [
  ['Robert', 22],
  ['Roberta', 22],
  ['Foo', 42],
  ['Bar', 18]
]

people_objects = [
  Person.new('Robert', 22),
  Person.new('Roberta', 22),
  Person.new('Foo', 42),
  Person.new('Bar', 17),
]

1 - Wildcard Matching

Qo has a concept of a Wildcard, Any, which will match against any value

Qo[Any, Any] === ['Robert', 22] # true

A single wildcard will match anything, and can frequently be used as an always true:

Qo[Any] === :literally_anything_here

2 - Array Matching

The first way a Qo matcher can be defined is by using *varargs:

Qo::Matchers::BaseMatcher(type, *varargs, **kwargs)

This gives us the and matcher shorthand for array matchers.

2.1 - Array matched against an Array

When an Array matcher is run against an Array, it will compare elements by index in the following priority:

  1. Does it case match (===)?
  2. Does it have a predicate method by that name that matches?

This functionality is left biased and permissive, meaning that if the right side of the argument is longer it will ignore those items in the match. If it's shorter? Not so much.

2.1.1 - Case Match present

We've seen some case matching so far with Range and Regex:

# Standalone

Qo[/Rob/, Any] === ['Robert', 22]
# => true

# Case statement

case ['Roberta', 22]
when Qo[Any, 0..9] then 'child'
when Qo[Any, 10..19] then 'teen'
when Qo[Any, 20..99] then 'adult'
else 'not sure'
end
# => 'adult'

# Select

people_arrays.select(&Qo[Any, 10..19])
# => [['Bar', 18]]
2.1.2 - Predicate Method matched

If no case match is found, it will attempt to see if a predicate method by the same name exists, call it, and check the result:

dirty_values = [nil, '', true]

# Standalone

Qo[:nil?] === [nil]
# => true, though you could also just use Qo[nil]

# Case statement

case ['Roberta', nil]
when Qo[Any, :nil?] then 'no age'
else 'not sure'
end
# => 'no age'

# Select

people_arrays.select(&Qo[Any, :even?])
# => [["Robert", 22], ["Roberta", 22], ["Foo", 42], ["Bar", 18]]

2.2 - Array matched against an Object

When an Array matcher is matched against anything other than an Array it will follow the priority:

  1. Does it case match (===)?
  2. Does it have a predicate method by that name that matches?

Every argument provided will be run against the target object.

2.2.1 - Case Match present
# Standalone

Qo[Integer, 15..25] === 20
# => true

# Case statement - functionally indistinguishable from a regular case statement

# Select

[nil, '', 10, 'string'].select(&Qo.or(/str/, 10..20))
# => [10, "string"]
2.2.2 - Predicate Method matched

Now this is where some of the fun starts in

# Standalone

Qo.or(:nil?, :empty?) === nil
# => true
Qo.not(:nil?, :empty?) === nil
# => false

# Case statement

case 42
when Qo[Integer, :even?, 40..50] then 'oddly specific number criteria'
else 'nope'
end
# => "oddly specific number criteria"

# Reject

[nil, '', 10, 'string'].reject(&Qo.or(:nil?, :empty?))
# => [10, "string"]

3 - Hash Matching

3.1 - Hash matched against a Hash

  1. Does the key exist on the other hash?
  2. Are the match value and match target hashes?
  3. Does the target object's value case match against the match value?
  4. Does the target object's value predicate match against the match value?
  5. What about the String version of the match key? Abort if it can't coerce.
3.1.1 - Key present

Checks to see if the key is even present on the other object, false if not.

3.1.2 - Match value and target are hashes

If both the match value (match_key: matcher) and the match target are hashes, Qo will begin a recursive descent starting at the match key until it finds a matcher to try out:

Qo[a: {b: {c: 5..15}}] === {a: {b: {c: 10}}}
# => true

# Na, no fun. Deeper!
Qo.and(a: {
  f: 5..15,
  b: {
    c: /foo/,
    d: 10..30
  }
}).call(a: {
  f: 10,
  b: {
    c: 'foobar',
    d: 20
  }
})
# => true

# It can get chaotic with `or` though. Anything anywhere in there matches and
# it'll pass.
Qo.or(a: {
  f: false,
  b: {
    c: /nope/,
    d: 10..30
  }
}).call(a: {
  f: 10,
  b: {
    c: 'foobar',
    d: 20
  }
})
3.1.3 - Case match present

If a case match is present for the key, it'll try and compare:

# Standalone

Qo[name: /Foo/] === {name: 'Foo'}
# => true

# Case statement

case {name: 'Foo', age: 42}
when Qo[age: 40..50] then 'Gotcha!'
else 'nope'
end
# => "Gotcha!"

# Select

people_hashes = people_arrays.map { |n, a| {name: n, age: a} }
people_hashes.select(&Qo[age: 15..25])
# => [{:name=>"Robert", :age=>22}, {:name=>"Roberta", :age=>22}, {:name=>"Bar", :age=>18}]
3.1.4 - Predicate match present

Much like our array friend above, if a predicate style method is present see if it'll work

# Standalone

Qo[name: :empty?] === {name: ''}
# => true

# Case statement

case {name: 'Foo', age: nil}
when Qo[age: :nil?] then 'No age provided!'
else 'nope'
end
# => "No age provided!"

# Reject

people_hashes = people_arrays.map { |(n,a)| {name: n, age: a} } << {name: 'Ghost', age: nil}
people_hashes.reject(&Qo[age: :nil?])
# => [{:name=>"Robert", :age=>22}, {:name=>"Roberta", :age=>22}, {:name=>"Bar", :age=>18}]

Careful though, if the key doesn't exist that won't match. I'll have to consider this one later.

3.1.5 - String variant present

Coerces the key into a string if possible, and sees if that can provide a valid case match

3.2 - Hash matched against an Object

  1. Does the object respond to the match key?
  2. Does the result of sending the match key as a method case match the provided value?
  3. Does a predicate method exist for it?
3.2.1 - Responds to match key

If it doesn't know how to deal with it, false out.

3.2.2 - Case match present

This is where we can get into some interesting code, much like the hash selections above

# Standalone

Qo[name: /Rob/] === people_objects.first
# => true

# Case statement

case people_objects.first
when Qo[name: /Rob/] then "It's Rob!"
else 'Na, not them'
end
# => "It's Rob!"

# Select

people_objects.select(&Qo[name: /Rob/])
# => [Person(Robert, 22), Person(Roberta, 22)]
3.2.3 - Predicate match present
# Standalone

Qo[name: :empty?] === Person.new('', 22)
# => true

# Case statement

case Person.new('', nil)
when Qo[age: :nil?] then 'No age provided!'
else 'nope'
end
# => "No age provided!"

# Select

people_hashes.select(&Qo[age: :nil?])
# => []

4 - Right Hand Pattern Matching

This is where I start going a bit off into the weeds. We're going to try and get RHA style pattern matching in Ruby.

Qo.case(['Robert', 22]) { |m|
  m.when(Any, 20..99) { |n, a| "#{n} is an adult that is #{a} years old" }
  m.else
}
# => "Robert is an adult that is 22 years old"
Qo.case(people_objects.first) { |m|
  m.when(name: Any, age: 20..99) { |person| "#{person.name} is an adult that is #{person.age} years old" }
  m.else
}

In this case it's trying to do a few things:

  1. Iterate over every matcher until it finds a match
  2. Execute its block function

If no block function is provided, it assumes an identity function (-> v { v }) instead. If no match is found, nil will be returned.

name_longer_than_three = -> person { person.name.size > 3 }

people_objects.map(&Qo.match { |m|
  m.when(name_longer_than_three) { |person| Person.new(person.name[0..2], person.age) }

  m.else
})

# => [Person(age: 22, name: "Rob"), Person(age: 22, name: "Rob"), Person(age: 42, name: "Foo"), Person(age: 17, name: "Bar")]

So we just truncated everyone's name that was longer than three characters.

5 - Helper functions

There are a few functions added for convenience, and it should be noted that because all Qo matchers respond to === that they can be used as helpers as well.

5.1 - Dig

Dig is used to get in deep at a nested hash value. It takes a dot-path and a === respondent matcher:

Qo.dig('a.b.c', Qo.or(1..5, 15..25)) === {a: {b: {c: 1}}}
# => true

Qo.dig('a.b.c', Qo.or(1..5, 15..25)) === {a: {b: {c: 20}}}
# => true

To be fair that means anything that can respond to ===, including classes and other such things.

5.2 - Count By

This ends up coming up a lot, especially around querying, so let's get a way to count by!

Qo.count_by([1,2,3,2,2,2,1])

# => {
#   1 => 2,
#   2 => 4,
#   3 => 1
# }

Qo.count_by([1,2,3,2,2,2,1], &:even?)

# => {
#   false => 3,
#   true  => 4
# }

This feature may be added to Ruby 2.6+: https://bugs.ruby-lang.org/issues/11076

6 - Custom Pattern Matchers

With the release of Qo 1.0.0 we introduced the idea of custom branches and pattern matchers for more advanced users of the library.

Consider a Monadic type like Some and None:

# Technically Some and None don't exist yet, so we have to "cheat" instead
# of just saying `Some` for the precondition
#
# We start by defining two branches that match against a Some type and a None
# type, extracting the value on match before yielding to their associated
# functions
SomeBranch = Qo.create_branch(
  name:        'some',
  precondition: -> v { v.is_a?(Some) },
  extractor:    :value
)

NoneBranch = Qo.create_branch(
  name:        'none',
  precondition: -> v { v.is_a?(None) },
  extractor:    :value
)

# Now we create a new pattern matching class with those branches. Note that
# there's nothing stopping you from making as many branches as you want,
# except that it may get confusing after a while.
SomePatternMatch = Qo.create_pattern_match(branches: [
  SomeBranch,
  NoneBranch
])

class Some
  # There's also a provided mixin that gives an `match` method that
  # works exactly like a pattern match without having to use it explicitly
  include SomePatternMatch.mixin

  attr_reader :value

  def initialize(value) @value = value end
  def self.[](value)    new(value)     end

  def fmap(&fn)
    new_value = fn.call(value)
    new_value ? Some[new_value] : None[value]
  end
end

class None
  include SomePatternMatch.mixin

  attr_reader :value

  def initialize(value) @value = value end
  def self.[](value)    new(value)     end

  def fmap(&fn) None[value] end
end

# So now we can pattern match with `some` and `none` branches using the `match`
# method that was mixed into both types.
Some[1]
  .fmap { |v| v * 2 }
  .match { |m|
    m.some { |v| v + 100 }
    m.none { "OHNO!" }
  }
=> 102

Some[1]
  .fmap { |v| nil }
  .match { |m|
    m.some { |v| v + 100 }
    m.none { "OHNO!" }
  }
=> "OHNO!"

7 - Hacky Fun Time

These examples will grow over the next few weeks as I think of more fun things to do with Qo. PRs welcome if you find fun uses!

7.1 - JSON and HTTP

Note that Qo does not support deep querying of hashes (yet)

7.1.1 - JSON Placeholder

Qo tries to be clever though, it assumes Symbol keys first and then String keys, so how about some JSON?:

require 'json'
require 'net/http'

posts = JSON.parse(
  Net::HTTP.get(URI("https://jsonplaceholder.typicode.com/posts")), symbolize_names: true
)

users = JSON.parse(
  Net::HTTP.get(URI("https://jsonplaceholder.typicode.com/users")), symbolize_names: true
)

# Get all posts where the userId is 1.
posts.select(&Qo[userId: 1])

# Get users named Nicholas or have two names and an address somewhere with a zipcode
# that starts with 9 or 4.
#
# Qo matchers return a `===` respondant object, remember, so we can totally nest them.
users.select(&Qo.and(
  name: Qo.or(/^Nicholas/, /^\w+ \w+$/),
  address: {
    zipcode: Qo.or(/^9/, /^4/)
  }
))

# We could even use dig to get at some of the same information. This and the above will
# return the same results even.
users.select(&Qo.and(
  Qo.dig('address.zipcode', Qo.or(/^9/, /^4/)),
  name: Qo.or(/^Nicholas/, /^\w+ \w+$/)
))

Nifty!

Yield Self HTTP status matching

You can even use #yield_self to pipe values into a pattern matching block. In this particular case it'll let you check against the type signatures of the HTTP responses.

def get_url(url)
  Net::HTTP.get_response(URI(url)).yield_self(&Qo.match { |m|
    m.when(Net::HTTPSuccess) { |response| response.body.size },
    m.else                   { |response| raise response.message }
  })
end

get_url('https://github.com/baweaver/qo')
# => 142387
get_url('https://github.com/baweaver/qo/does_not_exist')
# => RuntimeError: Not Found

The difference between this and case? Well, the first is you can pass this to yield_self for a more inline solution. The second is that any Qo matcher can be used in there, including predicate and content checks on the body:

m.when(Net::HTTPSuccess, body: /Qo/)

You could put as many checks as you want in there, or use different Qo matchers nested to get even further in.

Now if we wanted to add more power and create an HTTP matcher:

HTTP_Matcher = Qo.create_pattern_match(branches: [
  Qo.create_branch(name: 'success', precondition: Net::HTTPSuccess),
  Qo.create_branch(name: 'error', precondition: Net::HTTPError),
  Qo::Braches::ElseBranch
])

def get_url(url)
  Net::HTTP.get_response(URI(url)).then(&HTTP_Matcher.match { |m|
    m.success { |response| response.body.size },
    m.else    { |response| raise response.message }
  })
end

7.2 - Opsy Stuff

7.2.1 - NMap

What about NMap for our Opsy friends? Well, simulated, but still fun.

hosts = (`nmap -oG - -sP 192.168.1.* 10.0.0.* | grep Host`).lines.map { |v| v.split[1..2] }
=> [["192.168.1.1", "(Router)"], ["192.168.1.2", "(My Computer)"], ["10.0.0.1", "(Gateway)"]]

hosts.select(&Qo[IPAddr.new('192.168.1.1/8')])
=> [["192.168.1.1", "(Router)"], ["192.168.1.2", "(My Computer)"]]
7.2.2 - df

The nice thing about Unix style commands is that they use headers, which means CSV can get a hold of them for some good formatting. It's also smart enough to deal with space separators that may vary in length:

rows = CSV.new(`df -h`, col_sep: " ", headers: true).read.map(&:to_h)

rows.map(&Qo.match { |m|
  m.when(Avail: /Gi$/) { |row|
    "#{row['Filesystem']} mounted on #{row['Mounted']} [#{row['Avail']} / #{row['Size']}]"
  }
}).compact
# => ["/dev/***** mounted on / [186Gi / 466Gi]"]

Installation

Add this line to your application's Gemfile:

gem 'qo'

And then execute:

$ bundle

Or install it yourself as:

$ gem install qo

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/baweaver/qo. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Qo projectโ€™s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

qo's People

Contributors

baweaver avatar bolshakov avatar havenwood avatar tbroadley avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

qo's Issues

Matchable, Descructurable, ...

Consider breaking the API into mixin modules like:

  • Matchable
  • Destructurable
  • PatternMatchable

It'd allow for things in PatternMatch like unless all_are(PatternMatchable)

This will help once we get into more advanced ideas like destructured arrays, hashes, objects, case classes, and different variants of pattern matches

[2.4.2] Object queries not working as described

version = ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-darwin16]

using Person object

class Person
  attr_reader :name, :age
  def initialize(n, a)
    @name = n
    @age = a
  end
end

qo query youth = people.select(&Qo[age: 0..18]) gives the following error

matcher.rb:149:in `block in hash_against_object_matcher': 
  wrong number of arguments (given 1, expected 2) (ArgumentError)

Additional Hash Functionality

Currently Qo can only go one level deep on hashes with matchers. Everything else would be compared normally.

Consider adding deep hash querying or methods to help with it like a dig wrapper of some sorts:

# source data: https://jsonplaceholder.typicode.com/users
Qo[
  Qo.dig('address.zipcode', /92998/)
] == users.first 

# Raw implementation - have to account for symbol or string keys
def dig(path_string, matcher)
  path_segments = path_string.split('.')
  path_sym_segments = path_segments.map(&:to_sym)

  -> target {  
    matcher === target.dig(*path_segments) ||
    matcher === target.dig(*path_sym_segments)
  }
end

This would implement the same API as dig with a proc. As dig returns nil for a non-match we can just check the final === match regardless. I do wonder what it'd do with indexes like dig supports though. Not sure if that works with strings.

This does raise the question though of whether or not a similar behavior should be present on objects. It could be emulated using reduce:

value = dig_path.split('.').reduce(target) { |v, s|
  break nil unless v.respond_to?(s)
  v.public_send(s)
}

matcher === value

Anyways, there are some questions I should answer before writing this one.

Permissive vs Strict evaluation

There are a number of cases where Qo could lean towards being more permissive or more strict. This issue is entirely for discussion on the merits of each, and to see if there's enough support for having both as an option.

Array Length

Qo[1,2] === [1,2,3]

Qo is currently permissive of non-matching lengths when comparing Arrays to Arrays

Hash Keys and Method Names

Qo[not_exist: :*] === {n: 1}

Qo will currently count this as a miss as the key not_exist does not exist on the target hash.

Qo[not_exist: :*] === OpenStruct.new(n: 1)

The same will apply for objects in general. If they don't respond_to? a field Qo will count it as a miss.


There are a few other cases, but that gives a general idea of what I mean. Thoughts?

Break out Matcher and Qo

Matcher is starting to get a little hairy. Let's break it out into some component pieces:

Matcher

  • array_matcher
  • hash_matcher

Qo

  • util
  • constructors
  • pattern_matcher

This will probably help with the testing stories as well in getting things a bit more self-contained.

Noted that it'll also help with building out some of the more advanced inheritance schemes for mappers later on.

Scala case class / Elixir-like :ok :error RHA matchers?

Was reading a bit through some of the other pattern matching libraries and over
some Elixir, and noticed dry-ruby/dry-matcher
used a system like this:

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)

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!"

The primary system of it could be implemented with Qo immediately as such:

result = Qo.match([:ok, 'success!'],
  Qo.m(:ok)              { |_, response| "Yay: #{response}" },
  Qo.m(:err, :not_found) { |_, response| "Oops, not found: #{response}" },
  Qo.m(:err)             { |_, response| "Boo: #{response}" },
)

result # => => "Yay: success!"

Note that Qo is left-biased permissive. It doesn't care about other positions in
the right hand array past its matcher size.

I almost wonder if it would be worthwhile to make an extra deconstruction matcher
to deal with this type of response as I could see a good amount of potential
value. GuardBlockMatcher is already fairly close to this idea, it just wraps
in [false, false] or [true, response].

Proposed API idea:

result = Qo.match([:ok, 'success!'],
  Qo.ok                { |response| "Yay: #{response}" },
  Qo.error(:not_found) { |response| "Oops, not found: #{response}" },
  Qo.error             { |response| "Boo: #{response}" },
)

Essentially strip off all arguments until it hits the response, getting us a few
steps closer to more natural right-hand assignment.

The only problem I see with this is that it strays from Qo's core goal of
keeping a Ruby-like feel as much as possible.

Another idea which we could crib from Scala are case class matchers:

abstract class Notification

case class Email(sender: String, title: String, body: String) extends Notification
case class SMS(caller: String, message: String) extends Notification
case class VoiceRecording(contactName: String, link: String) extends Notification

notification match {
  case Email(email, title, _) =>
    s"You got an email from $email with title: $title"
  case SMS(number, message) =>
    s"You got an SMS from $number! Message: $message"
  case VoiceRecording(name, link) =>
    s"You received a Voice Recording from $name! Click the link to hear it: $link"
  case other => other
}

Which would be interesting to implement, potentially doable with HashMatcher-like items:

# Experimental ideas, probably won't compile
class Qo::Matchers::CaseClass < Qo::Matchers::HashMatcher
  def to_proc
    Proc.new { |target|
      did_match = super[target]

      next NON_MATCH unless did_match

      # My kingdom for Hash set operations in Ruby 2.6
      matched_keys = target.slice(*@keyword_matchers.keys)
      extract_data = target.select { |k, v| matched_keys.include?(k) }.values

      [true, @fn.call(*extract_data)]
    }
  end
end

EmailCase          = Qo.case_class[sender: String, title: String, body: String]
SmsCase            = Qo.case_class[caller: String, message: String]
VoiceRecordingCase = Qo.case_class[contactName: String, link: String]

Qo.match(notification,
  EmailCase { |email, title, body|
    "You got an email from #{email} with title: #{title}"
  },

  SmsCase { |number, message|
    "You got an SMS from #{number}! Message: #{message}"
  },

  VoiceRecordingCase { |name, link|
    "You received a Voice Recording from #{name}! Click the link to hear it: #{link}"
  },

  Qo.m(:*)
)

Which feels a lot more Ruby to me, and theoretically should work. I just need to
experiment a bit and come up with a bit more of a sane name than case_class for
it.

Ideas:

  • deconstruct (d)
  • case (c)

...yeah, I'll get back to everyone on that. Can't think of a great name right now.

More practical examples

Need to work on sourcing a few more practical examples to show what usage of Qo would look like in the wild.

This should also consider the performance implications of using it versus the expressiveness derived. Some cases may well not make any sense to use Qo for.

Segment Documentation

Consider breaking the documentation up into sections, files, maybe a nice fancy example page.

Improve specs

The specs have gotten a bit unweildy, time to organize them and sort things so they can work as a secondary set of documentation.

The more evil part of me kinda wants to generate the docs from the test examples, but that may well be overkill.

non-symbol array matchers assume method calls

Qo currently tries to use public_send when matching against anything that's not Array -> Array. To fix this it should instead try and use === first, and then try to call methods if possible.

Allow both positional and named arguments in the same query object

AFAICT, it isn't currently possible to check that a target object is of the correct type as well as having a property with a particular value.

# This is an example given in the README
Qo[Integer, 15..25] === 20

# But, this doesn't appear to work
MyKlass = Struct.new(:foo)
Qo[MyKlass, foo: 42] === MyKlass.new(42)

Handling deep collections

Qo can handle nested hashes fine, but what about arrays?

Qo.and(a: [10..15]) === {a: [10]}

Currently that would be false, but the question is how to make it true sanely.

Immediate ideas

The first is to use Array matches Array for positional matching:

Qo.and(a: Qo.and(10..15)) === {a: [10]}

Perhaps actually using all?, any?, and none?. Problem is that creates confusion in the API, mixing actual Ruby with the DSL in a way that contradicts.

Qo.and(a: Qo.all?(10..15)) === {a: [10]}

So likely there's going to need to be a separate syntax to handle it:

Qo.and(a: Qo.collection(10..15)) === {a: [10]}

collection would work with either hash or array, and would likely come in _and, _or, and _none variants with no suffix just being _and. Probably also need to give a wildcard key option as well for it, but that's pushing it.

Overall Opinion

This is going to be one of those odd cases on defining the API for Qo that we need to be careful of, because it could very easily be unintuitive.

Destructuring Matchers

Realized that left hand assignment may be possible as well with #to_ary and an assumption on order mattering. This technique can be shared for both left and right hand assignments and may feed well into the previous case class issue.

Proposed API:

name, age = Qo.d(name: /foo/, age: 10..20).call(person)

Non matching values would return nil, but the possibilities for this one are roughly analogous to an Object#values_at function. Array and Hash are already pretty straightforward here.

Kinda wonder if an array of symbols would make sense for objects here.

Backreferences?

What's the Qo-ish way to affect something similar to backreferences?

For example, match a Hash where the value of the :foo key matches the value of the :bar key.

# This doesn't work
Qo[:foo => :bar, :bar => :foo] === {foo:42, bar:42}    # => false

Enable CI builds

There were some issues with other versions of Ruby, so we need to get CI builds running to verify changes.

Performance Benchmarks

Going to have to get some qualified benchmarks going on the public APIs to verify speeds.

They should be reproducible in a rake task of some sorts.

Implement psuedo-RHA matching

Proposed API:

data = %w(foo bar)

value = Qo.match(data,
  Qo[String, String] { |a, b| a + b },
  Qo[String, Integer] { |s, i| s * i }
)

value # 'foobar'

When Qo is passed a block, it shall yield the data to that block in its entirety.

Known potential issues:

Falsyness

What if a yield returns a falsy value? Does that mean it goes to the next match? Ideally this should see that it was a match and use that as a reason to call the block.

That means these should likely be separate steps when used outside of a match. Need to think of how to manage that though.

Checking a Hash for the presence of a key

It's currently possible to check that a Hash contains a given key and that the key's value matches a desired value. However, checking for the presence of a key without checking the key's value (ie. a matcher that's equivalent to Hash#key?) isn't possible.

My first thought was to try Qo[key? => :foo], but that fails with an ArgumentError because key? needs an argument.

After reading the source I thought that Qo[ [:key?, :foo] => true ] would work, but it didn't, presumably because the matcher argument to public_send isn't being splatted. Unfortunately, even if the calls to public_send were "fixed", this would be a tough solution to come up with without reading the code very carefully, and of course it would be an implementation-dependent hack.

I humbly suggest extending the API to allow for something like Qo.key?[:foo] for handling this use-case. Another option would be to have HashMatcher special-case the key? key.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.