qo 0.1.10 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +18 -12
- data/Rakefile +72 -0
- data/lib/qo.rb +17 -84
- data/lib/qo/exceptions.rb +39 -0
- data/lib/qo/helpers.rb +37 -0
- data/lib/qo/matchers/array_matcher.rb +63 -0
- data/lib/qo/matchers/base_matcher.rb +104 -0
- data/lib/qo/matchers/guard_block_matcher.rb +37 -0
- data/lib/qo/matchers/hash_matcher.rb +131 -0
- data/lib/qo/matchers/pattern_match.rb +93 -0
- data/lib/qo/public_api.rb +121 -0
- data/lib/qo/version.rb +1 -1
- data/performance_report.txt +24 -24
- metadata +10 -4
- data/lib/qo/guard_block_matcher.rb +0 -19
- data/lib/qo/matcher.rb +0 -286
@@ -0,0 +1,37 @@
|
|
1
|
+
module Qo
|
2
|
+
module Matchers
|
3
|
+
# A GuardBlockMatcher is like a regular matcher, except in that if it
|
4
|
+
# "matches" it will provide its match target to its associated block.
|
5
|
+
#
|
6
|
+
# It returns tuples of (status, result) in order to prevent masking of
|
7
|
+
# legitimate falsy or nil values returned.
|
8
|
+
#
|
9
|
+
# @author [baweaver]
|
10
|
+
#
|
11
|
+
class GuardBlockMatcher < BaseMatcher
|
12
|
+
# Identity function that returns its argument directly
|
13
|
+
IDENTITY = -> v { v }
|
14
|
+
|
15
|
+
# Definition of a non-match
|
16
|
+
NON_MATCH = [false, false]
|
17
|
+
|
18
|
+
def initialize(*array_matchers, **keyword_matchers, &fn)
|
19
|
+
@fn = fn || IDENTITY
|
20
|
+
|
21
|
+
super('and', *array_matchers, **keyword_matchers)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Overrides the base matcher's #to_proc to wrap the value in a status
|
25
|
+
# and potentially call through to the associated block if a base
|
26
|
+
# matcher would have passed
|
27
|
+
#
|
28
|
+
# @return [Proc]
|
29
|
+
# Any -> [Bool, Any] # (status, result) tuple
|
30
|
+
def to_proc
|
31
|
+
Proc.new { |target|
|
32
|
+
super[target] ? [true, @fn.call(target)] : NON_MATCH
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
module Qo
|
2
|
+
module Matchers
|
3
|
+
# A Hash Matcher is a matcher that uses only keyword args to define a sequence
|
4
|
+
# of matches to perform against either an Object or another Hash.
|
5
|
+
#
|
6
|
+
# In the case of a Hash matching against a Hash, it will compare the intersection
|
7
|
+
# of keys and match the values against eachother.
|
8
|
+
#
|
9
|
+
# In the case of a Hash matching against an Object, it will treat the keys as
|
10
|
+
# method property invocations to be matched against the provided values.
|
11
|
+
#
|
12
|
+
# All variants present in the BaseMatcher are present here, including 'and',
|
13
|
+
# 'not', and 'or'.
|
14
|
+
#
|
15
|
+
# @author [baweaver]
|
16
|
+
#
|
17
|
+
class HashMatcher < BaseMatcher
|
18
|
+
# Used to match against a matcher made from an Array, like:
|
19
|
+
#
|
20
|
+
# Qo['Foo', 'Bar']
|
21
|
+
#
|
22
|
+
# @param matchers [Array[respond_to?(===)]] indexed tuple to match the target object against
|
23
|
+
#
|
24
|
+
# @return [Proc[Any]]
|
25
|
+
# Array -> Bool # Tuple match against targets index
|
26
|
+
# Object -> Bool # Boolean public send
|
27
|
+
def to_proc
|
28
|
+
Proc.new { |target| self.call(target) }
|
29
|
+
end
|
30
|
+
|
31
|
+
# Used to match against a matcher made from Keyword Arguments (a Hash)
|
32
|
+
#
|
33
|
+
# @param matchers [Hash[Any, respond_to?(:===)]]
|
34
|
+
# Any key mapping to any value that responds to `===`. Notedly more
|
35
|
+
# satisfying when `===` does something fun.
|
36
|
+
#
|
37
|
+
# @return [Proc[Any]]
|
38
|
+
# Hash -> Bool # Value matching against similar keys, will attempt to coerce to_s because JSON
|
39
|
+
# Object -> Bool # Uses keys as methods with public send to `===` match against the value
|
40
|
+
def call(target)
|
41
|
+
return true if @keyword_matchers == target
|
42
|
+
|
43
|
+
match_fn = target.is_a?(::Hash) ?
|
44
|
+
Proc.new { |match_key, matcher| match_hash_value?(target, match_key, matcher) } :
|
45
|
+
Proc.new { |match_key, matcher| match_object_value?(target, match_key, matcher) }
|
46
|
+
|
47
|
+
match_with(@keyword_matchers, &match_fn)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Checks if a hash value matches a given matcher
|
51
|
+
#
|
52
|
+
# @param target [Any] Target of the match
|
53
|
+
# @param match_key [Symbol] Key of the hash to reference
|
54
|
+
# @param matcher [respond_to?(:===)] Any matcher responding to ===
|
55
|
+
#
|
56
|
+
# @return [Boolean] Match status
|
57
|
+
private def match_hash_value?(target, match_key, matcher)
|
58
|
+
return false unless target.key?(match_key)
|
59
|
+
return true if wildcard_match?(matcher)
|
60
|
+
|
61
|
+
return hash_recurse(target[match_key], matcher) if target.is_a?(Hash) && matcher.is_a?(Hash)
|
62
|
+
|
63
|
+
hash_case_match?(target, match_key, matcher) ||
|
64
|
+
hash_method_predicate_match?(target, match_key, matcher)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Checks if an object property matches a given matcher
|
68
|
+
#
|
69
|
+
# @param target [Any] Target of the match
|
70
|
+
# @param match_property [Symbol] Property of the object to reference
|
71
|
+
# @param matcher [respond_to?(:===)] Any matcher responding to ===
|
72
|
+
#
|
73
|
+
# @return [Boolean] Match status
|
74
|
+
private def match_object_value?(target, match_property, matcher)
|
75
|
+
return false unless target.respond_to?(match_property)
|
76
|
+
|
77
|
+
wildcard_match?(matcher) ||
|
78
|
+
hash_method_case_match?(target, match_property, matcher)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Double wraps case match in order to ensure that we try against both Symbol
|
82
|
+
# and String variants of the keys, as this is a very common mixup in Ruby.
|
83
|
+
#
|
84
|
+
# @param target [Hash] Target of the match
|
85
|
+
# @param match_key [Symbol] Key to match against
|
86
|
+
# @param matcher [respond_to?(:===)] Matcher
|
87
|
+
#
|
88
|
+
# @return [Boolean]
|
89
|
+
private def hash_case_match?(target, match_key, matcher)
|
90
|
+
return true if case_match?(target[match_key], matcher)
|
91
|
+
|
92
|
+
match_key.respond_to?(:to_s) &&
|
93
|
+
target.key?(match_key.to_s) &&
|
94
|
+
case_match?(target[match_key.to_s], matcher)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Attempts to run a matcher as a predicate method against the target
|
98
|
+
#
|
99
|
+
# @param target [Hash] Target of the match
|
100
|
+
# @param match_key [Symbol] Method to call
|
101
|
+
# @param match_predicate [Symbol] Matcher
|
102
|
+
#
|
103
|
+
# @return [Boolean]
|
104
|
+
private def hash_method_predicate_match?(target, match_key, match_predicate)
|
105
|
+
method_matches?(target[match_key], match_predicate)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Attempts to run a case match against a method call derived from a hash
|
109
|
+
# key, and checks the result.
|
110
|
+
#
|
111
|
+
# @param target [Hash] Target of the match
|
112
|
+
# @param match_property [Symbol] Method to call
|
113
|
+
# @param matcher [respond_to?(:===)] Matcher
|
114
|
+
#
|
115
|
+
# @return [Boolean]
|
116
|
+
private def hash_method_case_match?(target, match_property, matcher)
|
117
|
+
case_match?(method_send(target, match_property), matcher)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Recurses on nested hashes.
|
121
|
+
#
|
122
|
+
# @param target [Hash]
|
123
|
+
# @param matcher [Hash]
|
124
|
+
#
|
125
|
+
# @return [Boolean]
|
126
|
+
private def hash_recurse(target, matcher)
|
127
|
+
Qo::Matchers::HashMatcher.new(@type, **matcher).call(target)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'qo/exceptions'
|
2
|
+
|
3
|
+
module Qo
|
4
|
+
module Matchers
|
5
|
+
# Creates a PatternMatch, which will evaluate once given a match target.
|
6
|
+
#
|
7
|
+
# Each GuardBlockMatcher given will be run in sequence until a "match" is
|
8
|
+
# located. Once that condition is met, it will call the associated block
|
9
|
+
# function of that GuardBlockMatcher with the match target.
|
10
|
+
#
|
11
|
+
# This is done as an effort to emulate Right Hand Assignment seen in other
|
12
|
+
# functionally oriented languages pattern matching systems. Most notably this
|
13
|
+
# is done in Scala: (https://docs.scala-lang.org/tour/pattern-matching.html)
|
14
|
+
#
|
15
|
+
# ```scala
|
16
|
+
# notification match {
|
17
|
+
# case Email(email, title, _) =>
|
18
|
+
# s"You got an email from $email with title: $title"
|
19
|
+
#
|
20
|
+
# case SMS(number, message) =>
|
21
|
+
# s"You got an SMS from $number! Message: $message"
|
22
|
+
#
|
23
|
+
# case VoiceRecording(name, link) =>
|
24
|
+
# s"You received a Voice Recording from $name! Click the link to hear it: $link"
|
25
|
+
#
|
26
|
+
# case other => other
|
27
|
+
# }
|
28
|
+
# ```
|
29
|
+
#
|
30
|
+
# Qo will instead pipe the entire matched object, and might look something like this:
|
31
|
+
#
|
32
|
+
# ```ruby
|
33
|
+
# # Assuming notification is in the form of a tuple
|
34
|
+
#
|
35
|
+
# Qo.match(notification,
|
36
|
+
# Qo.m(EMAIL_REGEX, String, :*) { |email, title, _|
|
37
|
+
# "You got an email from #{email} with title: #{title}"
|
38
|
+
# },
|
39
|
+
#
|
40
|
+
# Qo.m(PHONE_REGEX, String) { |number, message, _|
|
41
|
+
# "You got an SMS from #{number}! Message: #{message}"
|
42
|
+
# },
|
43
|
+
#
|
44
|
+
# Qo.m(String, LINK_REGEX) { |name, link, _|
|
45
|
+
# "You received a Voice Recording from #{name}! Click the link to hear it: #{link}"
|
46
|
+
# },
|
47
|
+
#
|
48
|
+
# Qo.m(:*)
|
49
|
+
# )
|
50
|
+
# ```
|
51
|
+
#
|
52
|
+
# Efforts to emulate the case class mechanic may be present in later versions,
|
53
|
+
# but for now the associated performance penalty may be too steep to consider.
|
54
|
+
#
|
55
|
+
# We'll evaluate those options in a few experiments later.
|
56
|
+
#
|
57
|
+
# @author [baweaver]
|
58
|
+
#
|
59
|
+
class PatternMatch
|
60
|
+
def initialize(*matchers)
|
61
|
+
raise Qo::Exceptions::NotAllGuardMatchersProvided unless matchers.all? { |q|
|
62
|
+
q.is_a?(Qo::Matchers::GuardBlockMatcher)
|
63
|
+
}
|
64
|
+
|
65
|
+
@matchers = matchers
|
66
|
+
end
|
67
|
+
|
68
|
+
# Function return of a PatternMatch waiting for a target to run
|
69
|
+
#
|
70
|
+
# @return [Proc]
|
71
|
+
# Any -> Any | nil
|
72
|
+
def to_proc
|
73
|
+
Proc.new { |target| self.call(target) }
|
74
|
+
end
|
75
|
+
|
76
|
+
# Immediately invokes a PatternMatch
|
77
|
+
#
|
78
|
+
# @param target [Any]
|
79
|
+
# Target to run against and pipe to the associated block if it
|
80
|
+
# "matches" any of the GuardBlocks
|
81
|
+
#
|
82
|
+
# @return [Any | nil] Result of the piped block, or nil on a miss
|
83
|
+
def call(target)
|
84
|
+
@matchers.each { |guard_block_matcher|
|
85
|
+
did_match, match_result = guard_block_matcher.call(target)
|
86
|
+
return match_result if did_match
|
87
|
+
}
|
88
|
+
|
89
|
+
nil
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Qo
|
2
|
+
# The Public API consists of methods that should be openly accessible to the
|
3
|
+
# top level Qo namespace, and should not change. It should be used as the
|
4
|
+
# subject of Acceptance level tests for the library and should not have its
|
5
|
+
# externally facing methods renamed or moved under pain of a look of profound
|
6
|
+
# disappointment from the creator.
|
7
|
+
#
|
8
|
+
# @author [baweaver]
|
9
|
+
#
|
10
|
+
module PublicApi
|
11
|
+
include Qo::Exceptions
|
12
|
+
|
13
|
+
# Creates an `and` type query matcher. All conditions in this type of matcher
|
14
|
+
# must pass to be considered a "match". It will short-circuit in the case of
|
15
|
+
# a false match.
|
16
|
+
#
|
17
|
+
# @param *array_matchers [Array] Array-like conditionals
|
18
|
+
# @param **keyword_matchers [Hash] Keyword style conditionals
|
19
|
+
#
|
20
|
+
# @return [Proc[Any]]
|
21
|
+
# Any -> Bool # Given a target, will return if it "matches"
|
22
|
+
def and(*array_matchers, **keyword_matchers)
|
23
|
+
create_matcher('and', *array_matchers, **keyword_matchers)
|
24
|
+
end
|
25
|
+
|
26
|
+
# The magic that lets you use `Qo[...]` instead of `Qo.and(...)`. Use wisely
|
27
|
+
alias_method :[], :and
|
28
|
+
|
29
|
+
# Creates an `or` type query matcher. Any conditions in this type of matcher
|
30
|
+
# must pass to be considered a "match". It will short-circuit in the case of
|
31
|
+
# a true match.
|
32
|
+
#
|
33
|
+
# @param *array_matchers [Array] Array-like conditionals
|
34
|
+
# @param **keyword_matchers [Hash] Keyword style conditionals
|
35
|
+
#
|
36
|
+
# @return [Proc[Any]]
|
37
|
+
# Any -> Bool # Given a target, will return if it "matches"
|
38
|
+
def or(*array_matchers, **keyword_matchers)
|
39
|
+
create_matcher('or', *array_matchers, **keyword_matchers)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Creates a `not` type query matcher. No conditions in this type of matcher
|
43
|
+
# should pass to be considered a "match". It will short-circuit in the case of
|
44
|
+
# a true match.
|
45
|
+
#
|
46
|
+
# @param *array_matchers [Array] Array-like conditionals
|
47
|
+
# @param **keyword_matchers [Hash] Keyword style conditionals
|
48
|
+
#
|
49
|
+
# @return [Proc[Any]]
|
50
|
+
# Any -> Bool # Given a target, will return if it "matches"
|
51
|
+
def not(*array_matchers, **keyword_matchers)
|
52
|
+
create_matcher('not', *array_matchers, **keyword_matchers)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Creates a Guard Block matcher.
|
56
|
+
#
|
57
|
+
# A guard block matcher is used to guard a function from running unless
|
58
|
+
# the left-hand matcher passes. Once called with a value, it will either
|
59
|
+
# return `[false, false]` or `[true, Any]`.
|
60
|
+
#
|
61
|
+
# This wrapping is done to preserve intended false or nil responses,
|
62
|
+
# and is unwrapped with match below.
|
63
|
+
#
|
64
|
+
# @param *array_matchers [Array] varargs matchers
|
65
|
+
# @param **keyword_matchers [Hash] kwargs matchers
|
66
|
+
# @param &fn [Proc] Guarded function
|
67
|
+
#
|
68
|
+
# @return [Proc[Any]]
|
69
|
+
# Any -> Proc[Any]
|
70
|
+
def matcher(*array_matchers, **keyword_matchers, &fn)
|
71
|
+
Qo::Matchers::GuardBlockMatcher.new(*array_matchers, **keyword_matchers, &fn)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Might be a tinge fond of shorthand
|
75
|
+
alias_method :m, :matcher
|
76
|
+
|
77
|
+
# "Curried" function that waits for a target, or evaluates immediately if given
|
78
|
+
# one.
|
79
|
+
#
|
80
|
+
# A PatternMatch will try and run all GuardBlock matchers in sequence until
|
81
|
+
# it finds one that "matches". Once found, it will pass the target into the
|
82
|
+
# associated matcher's block function.
|
83
|
+
#
|
84
|
+
# @param *args [Array[Any, *GuardBlockMatcher]]
|
85
|
+
# Collection of matchers to run, potentially prefixed by a target object
|
86
|
+
#
|
87
|
+
# @return [Qo::PatternMatch | Any]
|
88
|
+
# Returns a PatternMatch waiting for a target, or an evaluated PatternMatch response
|
89
|
+
def match(*args)
|
90
|
+
if args.first.is_a?(Qo::Matchers::GuardBlockMatcher)
|
91
|
+
Qo::Matchers::PatternMatch.new(*args)
|
92
|
+
else
|
93
|
+
match_target, *qo_matchers = args
|
94
|
+
Qo::Matchers::PatternMatch.new(*qo_matchers).call(match_target)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Abstraction for creating a matcher, allowing for common error handling scenarios.
|
99
|
+
#
|
100
|
+
# @param type [String] Type of matcher
|
101
|
+
# @param *array_matchers [Array] Array-like conditionals
|
102
|
+
# @param **keyword_matchers [Hash] Keyword style conditionals
|
103
|
+
#
|
104
|
+
# @raises Qo::Exceptions::NoMatchersProvided
|
105
|
+
# @raises Qo::Exceptions::MultipleMatchersProvided
|
106
|
+
#
|
107
|
+
# @return [Qo::Matcher]
|
108
|
+
private def create_matcher(type, *array_matchers, **keyword_matchers)
|
109
|
+
array_empty, hash_empty = array_matchers.empty?, keyword_matchers.empty?
|
110
|
+
|
111
|
+
raise Qo::NoMatchersProvided if array_empty && hash_empty
|
112
|
+
raise Qo::MultipleMatchersProvided if !(array_empty || hash_empty)
|
113
|
+
|
114
|
+
if hash_empty
|
115
|
+
Qo::Matchers::ArrayMatcher.new(type, *array_matchers)
|
116
|
+
else
|
117
|
+
Qo::Matchers::HashMatcher.new(type, **keyword_matchers)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
data/lib/qo/version.rb
CHANGED
data/performance_report.txt
CHANGED
@@ -3,58 +3,58 @@ Array * Array - Literal
|
|
3
3
|
=======================
|
4
4
|
|
5
5
|
Warming up --------------------------------------
|
6
|
-
Vanilla
|
7
|
-
Qo.and
|
6
|
+
Vanilla 283.062k i/100ms
|
7
|
+
Qo.and 48.476k i/100ms
|
8
8
|
Calculating -------------------------------------
|
9
|
-
Vanilla 9.
|
10
|
-
Qo.and
|
9
|
+
Vanilla 9.319M (± 2.1%) i/s - 46.705M in 5.013834s
|
10
|
+
Qo.and 573.855k (± 1.3%) i/s - 2.909M in 5.069323s
|
11
11
|
|
12
12
|
Comparison:
|
13
|
-
Vanilla:
|
14
|
-
Qo.and:
|
13
|
+
Vanilla: 9319318.3 i/s
|
14
|
+
Qo.and: 573855.1 i/s - 16.24x slower
|
15
15
|
|
16
16
|
|
17
17
|
Array * Array - Index pattern match
|
18
18
|
===================================
|
19
19
|
|
20
20
|
Warming up --------------------------------------
|
21
|
-
Vanilla
|
22
|
-
Qo.and
|
21
|
+
Vanilla 44.095k i/100ms
|
22
|
+
Qo.and 12.753k i/100ms
|
23
23
|
Calculating -------------------------------------
|
24
|
-
Vanilla
|
25
|
-
Qo.and
|
24
|
+
Vanilla 482.805k (± 5.9%) i/s - 2.425M in 5.041737s
|
25
|
+
Qo.and 128.970k (± 4.3%) i/s - 650.403k in 5.052438s
|
26
26
|
|
27
27
|
Comparison:
|
28
|
-
Vanilla:
|
29
|
-
Qo.and:
|
28
|
+
Vanilla: 482804.7 i/s
|
29
|
+
Qo.and: 128969.8 i/s - 3.74x slower
|
30
30
|
|
31
31
|
|
32
32
|
Array * Object - Predicate match
|
33
33
|
================================
|
34
34
|
|
35
35
|
Warming up --------------------------------------
|
36
|
-
Vanilla
|
37
|
-
Qo.and
|
36
|
+
Vanilla 138.847k i/100ms
|
37
|
+
Qo.and 16.157k i/100ms
|
38
38
|
Calculating -------------------------------------
|
39
|
-
Vanilla 2.
|
40
|
-
Qo.and
|
39
|
+
Vanilla 2.153M (± 3.9%) i/s - 10.830M in 5.037676s
|
40
|
+
Qo.and 174.879k (± 3.8%) i/s - 888.635k in 5.089311s
|
41
41
|
|
42
42
|
Comparison:
|
43
|
-
Vanilla:
|
44
|
-
Qo.and:
|
43
|
+
Vanilla: 2153240.3 i/s
|
44
|
+
Qo.and: 174878.6 i/s - 12.31x slower
|
45
45
|
|
46
46
|
|
47
47
|
Array * Array - Select index pattern match
|
48
48
|
==========================================
|
49
49
|
|
50
50
|
Warming up --------------------------------------
|
51
|
-
Vanilla
|
52
|
-
Qo.and
|
51
|
+
Vanilla 13.175k i/100ms
|
52
|
+
Qo.and 21.534k i/100ms
|
53
53
|
Calculating -------------------------------------
|
54
|
-
Vanilla
|
55
|
-
Qo.and
|
54
|
+
Vanilla 137.128k (± 3.6%) i/s - 685.100k in 5.002872s
|
55
|
+
Qo.and 240.447k (± 2.7%) i/s - 1.206M in 5.019112s
|
56
56
|
|
57
57
|
Comparison:
|
58
|
-
Qo.and:
|
59
|
-
Vanilla:
|
58
|
+
Qo.and: 240447.2 i/s
|
59
|
+
Vanilla: 137128.2 i/s - 1.75x slower
|
60
60
|
|