Skip to content

Commit

Permalink
Gave ze API a much needed haircut
Browse files Browse the repository at this point in the history
  • Loading branch information
baweaver committed Aug 7, 2018
1 parent 7b49c20 commit cc0690a
Show file tree
Hide file tree
Showing 18 changed files with 403 additions and 407 deletions.
94 changes: 50 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,43 +52,51 @@ How about some pattern matching? There are two styles:

#### Pattern Match

The original style
##### 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.

```ruby
# How about some "right-hand assignment" pattern matching
name_longer_than_three = -> person { person.name.size > 3 }
people_with_truncated_names = people.map(&Qo.match(
Qo.m(name_longer_than_three) { |person| Person.new(person.name[0..2], person.age) },
Qo.m(Any) # Identity function, catch-all
))
name_longer_than_three = -> person { person.name.size > 3 }

# And standalone like a case:
Qo.match(people.first,
Qo.m(age: 10..19) { |person| "#{person.name} is a teen that's #{person.age} years old" },
Qo.m(Any) { |person| "#{person.name} is #{person.age} years old" }
)
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
}
```

#### Pattern Match Block
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.

The new style, likely to take over in `v1.0.0` after testing:
##### 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.

```ruby
name_longer_than_three = -> person { person.name.size > 3 }
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(&:itself)
m.else
})

# And standalone like a case:
Qo.match(people.first) { |m|
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)
```

(More details coming on the difference and planned 1.0.0 APIs)

### Qo'isms

Qo supports three main types of queries: `and`, `or`, and `not`.
Expand Down Expand Up @@ -439,18 +447,18 @@ people_hashes.select(&Qo[age: :nil?])
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.

```ruby
Qo.match(['Robert', 22],
Qo.m(Any, 20..99) { |n, a| "#{n} is an adult that is #{a} years old" },
Qo.m(Any)
)
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"
```

```ruby
Qo.match(people_objects.first,
Qo.m(name: Any, age: 20..99) { |person| "#{person.name} is an adult that is #{person.age} years old" },
Qo.m(Any)
)
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:
Expand All @@ -460,18 +468,14 @@ In this case it's trying to do a few things:

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

If an initial target is not furnished, the matcher will become a curried proc awaiting a target. In more simple terms it just wants a target to run against, so let's give it a few with map:

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

people_objects.map(&Qo.match(
Qo.m(name_longer_than_three) { |person|
person.name = person.name[0..2]
person
},
Qo.m(Any)
))
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")]
```
Expand Down Expand Up @@ -517,6 +521,8 @@ Qo.count_by([1,2,3,2,2,2,1], &:even?)
# }
```

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

### 5 - 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!
Expand Down Expand Up @@ -573,10 +579,10 @@ HTTP responses.

```ruby
def get_url(url)
Net::HTTP.get_response(URI(url)).yield_self(&Qo.match(
Qo.m(Net::HTTPSuccess) { |response| response.body.size },
Qo.m(Any) { |response| raise response.message }
))
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')
Expand All @@ -590,7 +596,7 @@ The difference between this and case? Well, the first is you can pass this to
be used in there, including predicate and content checks on the body:

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

You could put as many checks as you want in there, or use different Qo matchers
Expand All @@ -617,11 +623,11 @@ The nice thing about Unix style commands is that they use headers, which means C
```ruby
rows = CSV.new(`df -h`, col_sep: " ", headers: true).read.map(&:to_h)

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

Expand Down
4 changes: 3 additions & 1 deletion lib/qo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

# Meta Matchers
require 'qo/matchers/pattern_match'
require 'qo/matchers/pattern_match_block'

# Helpers
require 'qo/helpers'
Expand All @@ -21,6 +20,9 @@
require 'qo/public_api'

module Qo
# Identity function that returns its argument directly
IDENTITY = -> v { v }

extend Qo::Exceptions
extend Qo::Helpers
extend Qo::PublicApi
Expand Down
13 changes: 0 additions & 13 deletions lib/qo/exceptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,6 @@ def to_s
end
end

# In the case of a Pattern Match, we need to ensure all arguments are
# GuardBlockMatchers.
#
# @author baweaver
# @since 0.2.0
#
class NotAllGuardMatchersProvided < ArgumentError
def to_s
"All provided matchers must be of type Qo::Matchers::GuardBlockMatcher " +
"defined with `Qo.matcher` or `Qo.m` instead of regular matchers."
end
end

# In the case of a Pattern Match, we should only have one "else" clause
#
# @author baweaver
Expand Down
17 changes: 12 additions & 5 deletions lib/qo/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ module Helpers
# @note This method will attempt to coerce path segments to Symbols
# if unsuccessful in first dig.
#
# @param path_map [String] Dot-delimited path
# @param expected_value [Any] Matcher
# @param path_map [String]
# Dot-delimited path
#
# @param expected_value [Any]
# Matcher
#
# @return [Proc]
# Hash -> Bool # Status of digging against the hash
Expand All @@ -22,10 +25,14 @@ def dig(path_map, expected_value)
# Counts by a function. This is entirely because I hackney this everywhere in
# pry anyways, so I want a function to do it for me already.
#
# @param targets [Array[Any]] Targets to count
# @param &fn [Proc] Function to define count key
# @param targets [Array[Any]]
# Targets to count
#
# @param &fn [Proc]
# Function to define count key
#
# @return [Hash[Any, Integer]] Counts
# @return [Hash[Any, Integer]]
# Counts
def count_by(targets, &fn)
fn ||= -> v { v }

Expand Down
15 changes: 15 additions & 0 deletions lib/qo/matchers/array_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ module Matchers
# # => true
# ```
#
# It should be noted that arrays of dissimilar size will result in an instant
# false return value. If you wish to do a single value match, simply use the
# provided `Any` type as such:
#
# ```ruby
# array.select(&Any)
# ```
#
# In the case of an Array matching against an Object, it will match each provided
# matcher against the object.
#
Expand All @@ -33,6 +41,11 @@ module Matchers
# @since 0.2.0
#
class ArrayMatcher < BaseMatcher
def initialize(type, array_matchers)
@array_matchers = array_matchers
@type = type
end

# Wrapper around call to allow for invocation in an Enumerable function,
# such as:
#
Expand All @@ -59,6 +72,8 @@ def call(target)
return true if @array_matchers == target

if target.is_a?(::Array)
return false unless target.size == @array_matchers.size

match_with(@array_matchers.each_with_index) { |matcher, i|
match_value?(target[i], matcher)
}
Expand Down
26 changes: 14 additions & 12 deletions lib/qo/matchers/base_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ module Matchers
# @since 0.2.0
#
class BaseMatcher
def initialize(type, *array_matchers, **keyword_matchers)
@array_matchers = array_matchers
@keyword_matchers = keyword_matchers
@type = type
def initialize(type, array_matchers, keyword_matchers)
@type = type

@full_matcher = array_matchers.empty? ?
Qo::Matchers::HashMatcher.new(type, keyword_matchers) :
Qo::Matchers::ArrayMatcher.new(type, array_matchers)
end

# Converts a Matcher to a proc for use in querying, such as:
Expand All @@ -34,9 +36,7 @@ def initialize(type, *array_matchers, **keyword_matchers)
#
# @return [Proc[Any]]
def to_proc
@array_matchers.empty? ?
Qo::Matchers::HashMatcher.new(@type, **@keyword_matchers).to_proc :
Qo::Matchers::ArrayMatcher.new(@type, *@array_matchers).to_proc
@full_matcher.to_proc
end

# You can directly call a matcher as well, much like a Proc,
Expand All @@ -46,7 +46,7 @@ def to_proc
#
# @return [Boolean] Result of the match
def call(target)
self.to_proc.call(target)
@full_matcher.call(target)
end

alias_method :===, :call
Expand All @@ -60,10 +60,12 @@ def call(target)
#
# @return [Boolean] Result of the match
private def match_with(collection, &fn)
return collection.any?(&fn) if @type == 'or'
return collection.none?(&fn) if @type == 'not'

collection.all?(&fn)
case @type
when 'and' then collection.all?(&fn)
when 'or' then collection.any?(&fn)
when 'not' then collection.none?(&fn)
else false
end
end

# Wraps a case equality statement to make it a bit easier to read. The
Expand Down
40 changes: 27 additions & 13 deletions lib/qo/matchers/guard_block_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ module Matchers
# you're going to want to read that documentation first.
#
# @example
# Qo::Matchers::GuardBlockMatcher == Qo.matcher == Qo.m
# Qo::Matchers::GuardBlockMatcher
#
# guard_matcher = Qo.m(Integer) { |v| v * 2 }
# guard_matcher = Qo::Matchers::GuardBlockMatcher.new(Integer) { |v| v * 2 }
# guard_matcher.call(1) # => [true, 2]
# guard_matcher.call(:x) # => [false, false]
#
Expand All @@ -23,28 +23,42 @@ module Matchers
# @since 0.1.5
#
class GuardBlockMatcher < BaseMatcher
# Identity function that returns its argument directly
IDENTITY = -> v { v }

# Definition of a non-match
NON_MATCH = [false, false]

def initialize(*array_matchers, **keyword_matchers, &fn)
@fn = fn || IDENTITY
def initialize(array_matchers, keyword_matchers, &fn)
@fn = fn || Qo::IDENTITY

super('and', *array_matchers, **keyword_matchers)
super('and', array_matchers, keyword_matchers)
end

# Overrides the base matcher's #to_proc to wrap the value in a status
# and potentially call through to the associated block if a base
# matcher would have passed
#
# @return [Proc[Any] - (Bool, Any)]
# (status, result) tuple
# @see Qo::Matchers::GuardBlockMatcher#call
#
# @return [Proc[Any]]
# Function awaiting target value
def to_proc
Proc.new { |target|
super[target] ? [true, @fn.call(target)] : NON_MATCH
}
Proc.new { |target| self.call(target) }
end


# Overrides the call method to wrap values in a return tuple to represent
# a positive match to guard against valid falsy returns
#
# @param target [Any]
# Target value to match against
#
# @return [Array[false, false]]
# The guard block did not match
#
# @return [Array[true, Any]]
# The guard block matched, and the provided function called through
# providing a return value.
def call(target)
super(target) ? [true, @fn.call(target)] : NON_MATCH
end
end
end
Expand Down
Loading

0 comments on commit cc0690a

Please sign in to comment.