pursuit 0.4.5 → 1.1.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/.github/workflows/rubygem.yaml +46 -0
- data/Gemfile +14 -13
- data/Gemfile.lock +14 -13
- data/README.md +210 -27
- data/bin/console +10 -0
- data/lib/pursuit/aggregate_modifier_not_found.rb +20 -0
- data/lib/pursuit/aggregate_modifier_required.rb +20 -0
- data/lib/pursuit/aggregate_modifiers_not_available.rb +13 -0
- data/lib/pursuit/attribute_not_found.rb +20 -0
- data/lib/pursuit/constants.rb +1 -1
- data/lib/pursuit/error.rb +7 -0
- data/lib/pursuit/predicate_parser.rb +181 -0
- data/lib/pursuit/predicate_search.rb +86 -0
- data/lib/pursuit/predicate_transform.rb +231 -0
- data/lib/pursuit/query_error.rb +7 -0
- data/lib/pursuit/simple_search.rb +66 -0
- data/lib/pursuit/term_parser.rb +44 -0
- data/lib/pursuit/term_search.rb +74 -0
- data/lib/pursuit/term_transform.rb +35 -0
- data/lib/pursuit.rb +18 -4
- data/pursuit.gemspec +4 -3
- data/spec/internal/app/models/application_record.rb +5 -0
- data/spec/internal/app/models/product.rb +3 -14
- data/spec/internal/app/models/product_category.rb +3 -1
- data/spec/internal/app/models/product_variation.rb +3 -1
- data/spec/internal/app/searches/product_category_search.rb +50 -0
- data/spec/internal/app/searches/product_search.rb +53 -0
- data/spec/internal/app/searches/product_variation_search.rb +53 -0
- data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
- data/spec/lib/pursuit/predicate_search_spec.rb +116 -0
- data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
- data/spec/lib/pursuit/simple_search_spec.rb +73 -0
- data/spec/lib/pursuit/term_parser_spec.rb +271 -0
- data/spec/lib/pursuit/term_search_spec.rb +85 -0
- data/spec/lib/pursuit/term_transform_spec.rb +105 -0
- metadata +50 -25
- data/.travis.yml +0 -26
- data/lib/pursuit/dsl.rb +0 -28
- data/lib/pursuit/railtie.rb +0 -13
- data/lib/pursuit/search.rb +0 -172
- data/lib/pursuit/search_options.rb +0 -86
- data/lib/pursuit/search_term_parser.rb +0 -46
- data/spec/lib/pursuit/dsl_spec.rb +0 -22
- data/spec/lib/pursuit/search_options_spec.rb +0 -146
- data/spec/lib/pursuit/search_spec.rb +0 -516
- data/spec/lib/pursuit/search_term_parser_spec.rb +0 -34
- data/travis/gemfiles/5.2.gemfile +0 -8
- data/travis/gemfiles/6.0.gemfile +0 -8
- data/travis/gemfiles/6.1.gemfile +0 -8
- data/travis/gemfiles/7.0.gemfile +0 -8
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Parser for predicate queries.
|
5
|
+
#
|
6
|
+
# Predicate queries take an attribute, an operator (such as the equal sign), and a value to compare with.
|
7
|
+
#
|
8
|
+
# For example, to search for records where the `first_name` attribute is equal to "John" and the `last_name`
|
9
|
+
# attribute contains either "Doe" or "Smith", you might use:
|
10
|
+
#
|
11
|
+
# => "first_name = John & (last_name ~ Doe | last_name ~ Smith)"
|
12
|
+
#
|
13
|
+
class PredicateParser < Parslet::Parser
|
14
|
+
# Whitespace
|
15
|
+
|
16
|
+
rule(:space) { match('\s').repeat(1) }
|
17
|
+
rule(:space?) { match('\s').repeat(0) }
|
18
|
+
|
19
|
+
# Boolean Types
|
20
|
+
|
21
|
+
rule(:boolean_true) { stri('true').as(:truthy) }
|
22
|
+
rule(:boolean_false) { stri('false').as(:falsey) }
|
23
|
+
rule(:boolean) { boolean_true | boolean_false }
|
24
|
+
|
25
|
+
# Numeric Types
|
26
|
+
|
27
|
+
rule(:numeric_prefix) do
|
28
|
+
str('+') | str('-')
|
29
|
+
end
|
30
|
+
|
31
|
+
rule(:integer) do
|
32
|
+
(numeric_prefix.maybe >> match('[0-9]').repeat(1)).as(:integer)
|
33
|
+
end
|
34
|
+
|
35
|
+
rule(:decimal) do
|
36
|
+
(numeric_prefix.maybe >> match('[0-9]').repeat(0) >> str('.') >> match('[0-9]').repeat(1)).as(:decimal)
|
37
|
+
end
|
38
|
+
|
39
|
+
rule(:number) do
|
40
|
+
decimal | integer
|
41
|
+
end
|
42
|
+
|
43
|
+
# Character Types
|
44
|
+
|
45
|
+
rule(:escaped_character) do
|
46
|
+
str('\\') >> match('.')
|
47
|
+
end
|
48
|
+
|
49
|
+
# String Types
|
50
|
+
|
51
|
+
rule(:string_double_quotes) do
|
52
|
+
str('"') >> (escaped_character | match('[^"]')).repeat(0).as(:string_double_quotes) >> str('"')
|
53
|
+
end
|
54
|
+
|
55
|
+
rule(:string_single_quotes) do
|
56
|
+
str("'") >> (escaped_character | match("[^']")).repeat(0).as(:string_single_quotes) >> str("'")
|
57
|
+
end
|
58
|
+
|
59
|
+
rule(:string_no_quotes) do
|
60
|
+
match("[\\w\\!\\'\\+\\,\\-\\.\\/\\:\\?\\@]").repeat(1).as(:string_no_quotes)
|
61
|
+
end
|
62
|
+
|
63
|
+
rule(:string) do
|
64
|
+
string_double_quotes | string_single_quotes | string_no_quotes
|
65
|
+
end
|
66
|
+
|
67
|
+
# Operators
|
68
|
+
|
69
|
+
rule(:operator_equal) { str('=') }
|
70
|
+
rule(:operator_not_equal) { str('!=') }
|
71
|
+
rule(:operator_contains) { str('~') }
|
72
|
+
rule(:operator_not_contains) { str('!~') }
|
73
|
+
rule(:operator_less_than) { str('<') }
|
74
|
+
rule(:operator_greater_than) { str('>') }
|
75
|
+
rule(:operator_less_than_or_equal_to) { str('<=') }
|
76
|
+
rule(:operator_greater_than_or_equal_to) { str('>=') }
|
77
|
+
rule(:operator_and) { str('&') }
|
78
|
+
rule(:operator_or) { str('|') }
|
79
|
+
|
80
|
+
rule(:comparator) do
|
81
|
+
(
|
82
|
+
operator_greater_than_or_equal_to |
|
83
|
+
operator_less_than_or_equal_to |
|
84
|
+
operator_greater_than |
|
85
|
+
operator_less_than |
|
86
|
+
operator_not_contains |
|
87
|
+
operator_contains |
|
88
|
+
operator_not_equal |
|
89
|
+
operator_equal
|
90
|
+
).as(:comparator)
|
91
|
+
end
|
92
|
+
|
93
|
+
rule(:joiner) do
|
94
|
+
(
|
95
|
+
operator_and |
|
96
|
+
operator_or
|
97
|
+
).as(:joiner)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Comparison Operands
|
101
|
+
|
102
|
+
rule(:aggregate_modifier) do
|
103
|
+
match('[\#\*\+\-\~]').as(:aggregate_modifier)
|
104
|
+
end
|
105
|
+
|
106
|
+
rule(:attribute) do
|
107
|
+
string.as(:attribute)
|
108
|
+
end
|
109
|
+
|
110
|
+
rule(:value) do
|
111
|
+
(boolean | number | string).as(:value)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Comparison
|
115
|
+
|
116
|
+
rule(:comparison) do
|
117
|
+
attribute >> space? >> comparator >> space? >> value
|
118
|
+
end
|
119
|
+
|
120
|
+
rule(:comparison_group) do
|
121
|
+
str('(') >> space? >> comparison_node >> space? >> str(')')
|
122
|
+
end
|
123
|
+
|
124
|
+
rule(:comparison_join) do
|
125
|
+
(comparison_group | comparison).as(:left) >> space? >> joiner >> space? >> comparison_node.as(:right)
|
126
|
+
end
|
127
|
+
|
128
|
+
rule(:comparison_node) do
|
129
|
+
comparison_join | comparison_group | comparison
|
130
|
+
end
|
131
|
+
|
132
|
+
# Aggregate Comparison
|
133
|
+
|
134
|
+
rule(:aggregate_comparison) do
|
135
|
+
aggregate_modifier >> attribute >> space? >> comparator >> space? >> value
|
136
|
+
end
|
137
|
+
|
138
|
+
rule(:aggregate_comparison_group) do
|
139
|
+
str('(') >> space? >> aggregate_comparison_node >> space? >> str(')')
|
140
|
+
end
|
141
|
+
|
142
|
+
rule(:aggregate_comparison_join) do
|
143
|
+
(aggregate_comparison_group | aggregate_comparison).as(:left) >>
|
144
|
+
space? >> joiner >> space? >> aggregate_comparison_node.as(:right)
|
145
|
+
end
|
146
|
+
|
147
|
+
rule(:aggregate_comparison_node) do
|
148
|
+
aggregate_comparison_join | aggregate_comparison_group | aggregate_comparison
|
149
|
+
end
|
150
|
+
|
151
|
+
# Predicate
|
152
|
+
|
153
|
+
rule(:predicate_where) do
|
154
|
+
comparison_node.as(:where)
|
155
|
+
end
|
156
|
+
|
157
|
+
rule(:predicate_having) do
|
158
|
+
aggregate_comparison_node.as(:having)
|
159
|
+
end
|
160
|
+
|
161
|
+
rule(:predicate) do
|
162
|
+
space? >> (
|
163
|
+
(predicate_where >> space? >> operator_and >> space? >> predicate_having) |
|
164
|
+
(predicate_having >> space? >> operator_and >> space? >> predicate_where) |
|
165
|
+
predicate_where |
|
166
|
+
predicate_having
|
167
|
+
) >> space?
|
168
|
+
end
|
169
|
+
|
170
|
+
root(:predicate)
|
171
|
+
|
172
|
+
# Helpers
|
173
|
+
|
174
|
+
def stri(string)
|
175
|
+
string
|
176
|
+
.each_char
|
177
|
+
.map { |c| match("[#{c.upcase}#{c.downcase}]") }
|
178
|
+
.reduce(:>>)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Provides an interface for declaring which attributes can be used in a predicate query, and a method for applying
|
5
|
+
# a predicate query to an `ActiveRecord::Relation` instance.
|
6
|
+
#
|
7
|
+
# @see Pursuit::PredicateParser
|
8
|
+
# @see Pursuit::PredicateTransform
|
9
|
+
#
|
10
|
+
class PredicateSearch
|
11
|
+
# @return [Arel::Table] The default table to retrieve attributes from.
|
12
|
+
#
|
13
|
+
attr_accessor :default_table
|
14
|
+
|
15
|
+
# @return [Boolean] `true` when aggregate modifiers can be used, `false` otherwise.
|
16
|
+
#
|
17
|
+
attr_accessor :permit_aggregate_modifiers
|
18
|
+
|
19
|
+
# @return [Hash<Symbol, Arel::Attributes::Attribute>] The attributes permitted for use in queries.
|
20
|
+
#
|
21
|
+
attr_accessor :permitted_attributes
|
22
|
+
|
23
|
+
# Creates a new predicate search instance.
|
24
|
+
#
|
25
|
+
# @param default_table [Arel::Table] The default table to retrieve attributes from.
|
26
|
+
# @param permit_aggregate_modifiers [Boolean] `true` when aggregate modifiers can be used, `false` otherwise.
|
27
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
28
|
+
#
|
29
|
+
def initialize(default_table: nil, permit_aggregate_modifiers: false, &block)
|
30
|
+
@default_table = default_table
|
31
|
+
@permit_aggregate_modifiers = permit_aggregate_modifiers
|
32
|
+
@permitted_attributes = HashWithIndifferentAccess.new
|
33
|
+
|
34
|
+
instance_eval(&block) if block
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Pursuit::PredicateParser] The parser which converts queries into trees.
|
38
|
+
#
|
39
|
+
def parser
|
40
|
+
@parser ||= PredicateParser.new
|
41
|
+
end
|
42
|
+
|
43
|
+
# @return [Pursuit::PredicateTransform] The transform which converts trees into ARel nodes.
|
44
|
+
#
|
45
|
+
def transform
|
46
|
+
@transform ||= PredicateTransform.new
|
47
|
+
end
|
48
|
+
|
49
|
+
# Permits use of the specified attribute in predicate queries.
|
50
|
+
#
|
51
|
+
# @param name [Symbol] The name used in the query.
|
52
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
53
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
54
|
+
#
|
55
|
+
def permit_attribute(name, attribute = nil)
|
56
|
+
attribute = default_table[attribute] if attribute.is_a?(Symbol)
|
57
|
+
permitted_attributes[name] = attribute || default_table[name]
|
58
|
+
end
|
59
|
+
|
60
|
+
# Parse a predicate query into ARel nodes.
|
61
|
+
#
|
62
|
+
# @param query [String] The predicate query.
|
63
|
+
# @return [Hash<Symbol, Arel::Nodes::Node>] The ARel nodes representing the predicate query.
|
64
|
+
#
|
65
|
+
def parse(query)
|
66
|
+
transform.apply(
|
67
|
+
parser.parse(query),
|
68
|
+
permit_aggregate_modifiers: permit_aggregate_modifiers,
|
69
|
+
permitted_attributes: permitted_attributes
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Applies the predicate clauses derived from `query` to `relation`.
|
74
|
+
#
|
75
|
+
# @param query [String] The predicate query.
|
76
|
+
# @param relation [ActiveRecord::Relation] The base relation to apply the predicate clauses to.
|
77
|
+
# @return [ActiveRecord::Relation] The base relation with the predicate clauses applied.
|
78
|
+
#
|
79
|
+
def apply(query, relation)
|
80
|
+
nodes = parse(query)
|
81
|
+
relation = relation.where(nodes[:where]) if nodes[:where]
|
82
|
+
relation = relation.having(nodes[:having]) if nodes[:having]
|
83
|
+
relation
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,231 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Transform for the tree produced by `Pursuit::PredicateParser`.
|
5
|
+
#
|
6
|
+
# @see Pursuit::PredicateParser
|
7
|
+
#
|
8
|
+
class PredicateTransform < Parslet::Transform
|
9
|
+
# @return [Hash<String, Symbol>] The list of supported aggregate modifiers, and the method name to invoke on the
|
10
|
+
# attribute node in order to obtain the function node.
|
11
|
+
#
|
12
|
+
AGGREGATE_MODIFIERS = {
|
13
|
+
'#' => :count,
|
14
|
+
'*' => :sum,
|
15
|
+
'+' => :maximum,
|
16
|
+
'-' => :minimum,
|
17
|
+
'~' => :average
|
18
|
+
}.freeze
|
19
|
+
|
20
|
+
# Boolean Types
|
21
|
+
|
22
|
+
rule(truthy: simple(:_)) { true }
|
23
|
+
rule(falsey: simple(:_)) { false }
|
24
|
+
|
25
|
+
# Numeric Types
|
26
|
+
|
27
|
+
rule(integer: simple(:value)) { Integer(value) }
|
28
|
+
rule(decimal: simple(:value)) { BigDecimal(value) }
|
29
|
+
|
30
|
+
# String Types
|
31
|
+
|
32
|
+
rule(string_double_quotes: []) { '' }
|
33
|
+
rule(string_double_quotes: simple(:value)) { value.to_s.gsub(/\\(.)/, '\1') }
|
34
|
+
|
35
|
+
rule(string_single_quotes: []) { '' }
|
36
|
+
rule(string_single_quotes: simple(:value)) { value.to_s.gsub(/\\(.)/, '\1') }
|
37
|
+
|
38
|
+
rule(string_no_quotes: simple(:value)) { value.to_s }
|
39
|
+
|
40
|
+
# Comparisons
|
41
|
+
|
42
|
+
rule(attribute: simple(:attribute), comparator: '=', value: simple(:value)) do |context|
|
43
|
+
eq_node(attribute(context), context[:value])
|
44
|
+
end
|
45
|
+
|
46
|
+
rule(attribute: simple(:attribute), comparator: '!=', value: simple(:value)) do |context|
|
47
|
+
not_eq_node(attribute(context), context[:value])
|
48
|
+
end
|
49
|
+
|
50
|
+
rule(attribute: simple(:attribute), comparator: '<', value: simple(:value)) do |context|
|
51
|
+
lt_node(attribute(context), context[:value])
|
52
|
+
end
|
53
|
+
|
54
|
+
rule(attribute: simple(:attribute), comparator: '>', value: simple(:value)) do |context|
|
55
|
+
gt_node(attribute(context), context[:value])
|
56
|
+
end
|
57
|
+
|
58
|
+
rule(attribute: simple(:attribute), comparator: '<=', value: simple(:value)) do |context|
|
59
|
+
lteq_node(attribute(context), context[:value])
|
60
|
+
end
|
61
|
+
|
62
|
+
rule(attribute: simple(:attribute), comparator: '>=', value: simple(:value)) do |context|
|
63
|
+
gteq_node(attribute(context), context[:value])
|
64
|
+
end
|
65
|
+
|
66
|
+
rule(attribute: simple(:attribute), comparator: '~', value: simple(:value)) do |context|
|
67
|
+
match_node(attribute(context), context[:value])
|
68
|
+
end
|
69
|
+
|
70
|
+
rule(attribute: simple(:attribute), comparator: '!~', value: simple(:value)) do |context|
|
71
|
+
does_not_match_node(attribute(context), context[:value])
|
72
|
+
end
|
73
|
+
|
74
|
+
# Aggregate Comparisons
|
75
|
+
|
76
|
+
rule(
|
77
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
78
|
+
attribute: simple(:attribute),
|
79
|
+
comparator: '=',
|
80
|
+
value: simple(:value)
|
81
|
+
) do |context|
|
82
|
+
eq_node(aggregate_attribute(context), context[:value])
|
83
|
+
end
|
84
|
+
|
85
|
+
rule(
|
86
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
87
|
+
attribute: simple(:attribute),
|
88
|
+
comparator: '!=',
|
89
|
+
value: simple(:value)
|
90
|
+
) do |context|
|
91
|
+
not_eq_node(aggregate_attribute(context), context[:value])
|
92
|
+
end
|
93
|
+
|
94
|
+
rule(
|
95
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
96
|
+
attribute: simple(:attribute),
|
97
|
+
comparator: '<',
|
98
|
+
value: simple(:value)
|
99
|
+
) do |context|
|
100
|
+
lt_node(aggregate_attribute(context), context[:value])
|
101
|
+
end
|
102
|
+
|
103
|
+
rule(
|
104
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
105
|
+
attribute: simple(:attribute),
|
106
|
+
comparator: '>',
|
107
|
+
value: simple(:value)
|
108
|
+
) do |context|
|
109
|
+
gt_node(aggregate_attribute(context), context[:value])
|
110
|
+
end
|
111
|
+
|
112
|
+
rule(
|
113
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
114
|
+
attribute: simple(:attribute),
|
115
|
+
comparator: '<=',
|
116
|
+
value: simple(:value)
|
117
|
+
) do |context|
|
118
|
+
lteq_node(aggregate_attribute(context), context[:value])
|
119
|
+
end
|
120
|
+
|
121
|
+
rule(
|
122
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
123
|
+
attribute: simple(:attribute),
|
124
|
+
comparator: '>=',
|
125
|
+
value: simple(:value)
|
126
|
+
) do |context|
|
127
|
+
gteq_node(aggregate_attribute(context), context[:value])
|
128
|
+
end
|
129
|
+
|
130
|
+
rule(
|
131
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
132
|
+
attribute: simple(:attribute),
|
133
|
+
comparator: '~',
|
134
|
+
value: simple(:value)
|
135
|
+
) do |context|
|
136
|
+
match_node(aggregate_attribute(context), context[:value])
|
137
|
+
end
|
138
|
+
|
139
|
+
rule(
|
140
|
+
aggregate_modifier: simple(:aggregate_modifier),
|
141
|
+
attribute: simple(:attribute),
|
142
|
+
comparator: '!~',
|
143
|
+
value: simple(:value)
|
144
|
+
) do |context|
|
145
|
+
does_not_match_node(aggregate_attribute(context), context[:value])
|
146
|
+
end
|
147
|
+
|
148
|
+
# Joins
|
149
|
+
|
150
|
+
rule(left: simple(:left), joiner: '&', right: simple(:right)) do
|
151
|
+
left.and(right)
|
152
|
+
end
|
153
|
+
|
154
|
+
rule(left: simple(:left), joiner: '|', right: simple(:right)) do
|
155
|
+
left.or(right)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Helpers
|
159
|
+
|
160
|
+
class << self
|
161
|
+
def attribute(context)
|
162
|
+
attribute_name = context[:attribute].to_sym
|
163
|
+
attribute = context.dig(:permitted_attributes, attribute_name)
|
164
|
+
raise AttributeNotFound, attribute_name if attribute.blank?
|
165
|
+
raise AggregateModifierRequired, attribute_name if attribute.name == Arel.star
|
166
|
+
|
167
|
+
attribute
|
168
|
+
end
|
169
|
+
|
170
|
+
def aggregate_attribute(context)
|
171
|
+
raise AggregateModifiersNotAvailable unless context[:permit_aggregate_modifiers]
|
172
|
+
|
173
|
+
attribute_name = context[:attribute].to_sym
|
174
|
+
attribute = context.dig(:permitted_attributes, attribute_name)
|
175
|
+
raise AttributeNotFound, attribute_name if attribute.blank?
|
176
|
+
|
177
|
+
aggregate_modifier_name = context[:aggregate_modifier].to_s
|
178
|
+
aggregate_modifier = AGGREGATE_MODIFIERS[aggregate_modifier_name]
|
179
|
+
raise AggregateModifierNotFound, aggregate_modifier_name unless aggregate_modifier
|
180
|
+
|
181
|
+
attribute.public_send(aggregate_modifier)
|
182
|
+
end
|
183
|
+
|
184
|
+
def eq_node(attribute, value)
|
185
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
186
|
+
return attribute.eq(value) if value.present?
|
187
|
+
|
188
|
+
attribute.eq(nil).or(attribute.matches_regexp('^\s*$'))
|
189
|
+
end
|
190
|
+
|
191
|
+
def not_eq_node(attribute, value)
|
192
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
193
|
+
return attribute.not_eq(value) if value.present?
|
194
|
+
|
195
|
+
attribute.not_eq(nil).and(attribute.does_not_match_regexp('^\s*$'))
|
196
|
+
end
|
197
|
+
|
198
|
+
def gt_node(attribute, value)
|
199
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
200
|
+
attribute.gt(value)
|
201
|
+
end
|
202
|
+
|
203
|
+
def gteq_node(attribute, value)
|
204
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
205
|
+
attribute.gteq(value)
|
206
|
+
end
|
207
|
+
|
208
|
+
def lt_node(attribute, value)
|
209
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
210
|
+
attribute.lt(value)
|
211
|
+
end
|
212
|
+
|
213
|
+
def lteq_node(attribute, value)
|
214
|
+
value = ActiveRecord::Base.sanitize_sql(value) if value.is_a?(String)
|
215
|
+
attribute.lteq(value)
|
216
|
+
end
|
217
|
+
|
218
|
+
def match_node(attribute, value)
|
219
|
+
value = ActiveRecord::Base.sanitize_sql_like(value) if value.is_a?(String)
|
220
|
+
value = value.blank? ? '%' : "%#{value}%"
|
221
|
+
attribute.matches(value)
|
222
|
+
end
|
223
|
+
|
224
|
+
def does_not_match_node(attribute, value)
|
225
|
+
value = ActiveRecord::Base.sanitize_sql_like(value) if value.is_a?(String)
|
226
|
+
value = value.blank? ? '%' : "%#{value}%"
|
227
|
+
attribute.does_not_match(value)
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Provides an interface for declaring which attributes should be searched in a simple query, and a method for applying
|
5
|
+
# a simple query to an `ActiveRecord::Relation` instance.
|
6
|
+
#
|
7
|
+
class SimpleSearch
|
8
|
+
# @return [Set<Arel::Attributes::Attribute>] The attributes to match against.
|
9
|
+
#
|
10
|
+
attr_accessor :attributes
|
11
|
+
|
12
|
+
# @return [Arel::Table] The default table to retrieve attributes from.
|
13
|
+
#
|
14
|
+
attr_accessor :default_table
|
15
|
+
|
16
|
+
# Creates a new simple search instance.
|
17
|
+
#
|
18
|
+
# @param default_table [Arel::Table] The default table to retrieve attributes from.
|
19
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
20
|
+
#
|
21
|
+
def initialize(default_table: nil, &block)
|
22
|
+
@attributes = Set.new
|
23
|
+
@default_table = default_table
|
24
|
+
|
25
|
+
instance_eval(&block) if block
|
26
|
+
end
|
27
|
+
|
28
|
+
# Adds an attribute to match against in queries.
|
29
|
+
#
|
30
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
31
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
32
|
+
#
|
33
|
+
def search_attribute(attribute)
|
34
|
+
attribute = default_table[attribute] if attribute.is_a?(Symbol)
|
35
|
+
attributes.add(attribute)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Parse a simple query into an ARel node.
|
39
|
+
#
|
40
|
+
# @param query [String] The simple query.
|
41
|
+
# @return [Arel::Nodes::Node] The ARel node representing the simple query.
|
42
|
+
#
|
43
|
+
def parse(query)
|
44
|
+
value = ActiveRecord::Base.sanitize_sql_like(query)
|
45
|
+
value = "%#{value}%"
|
46
|
+
|
47
|
+
attributes.inject(nil) do |previous_node, attribute|
|
48
|
+
node = attribute.matches(value)
|
49
|
+
next node unless previous_node
|
50
|
+
|
51
|
+
previous_node.or(node)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Applies the simple clauses derived from `query` to `relation`.
|
56
|
+
#
|
57
|
+
# @param query [String] The simple query.
|
58
|
+
# @param relation [ActiveRecord::Relation] The base relation to apply the simple clauses to.
|
59
|
+
# @return [ActiveRecord::Relation] The base relation with the simple clauses applied.
|
60
|
+
#
|
61
|
+
def apply(query, relation)
|
62
|
+
node = parse(query)
|
63
|
+
node ? relation.where(node) : relation.none
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Parser for a list of terms.
|
5
|
+
#
|
6
|
+
class TermParser < Parslet::Parser
|
7
|
+
# Whitespace
|
8
|
+
|
9
|
+
rule(:space) { match('\s').repeat(1) }
|
10
|
+
rule(:space?) { match('\s').repeat(0) }
|
11
|
+
|
12
|
+
# Character Types
|
13
|
+
|
14
|
+
rule(:escaped_character) do
|
15
|
+
str('\\') >> match('.')
|
16
|
+
end
|
17
|
+
|
18
|
+
# String Types
|
19
|
+
|
20
|
+
rule(:string_double_quotes) do
|
21
|
+
str('"') >> (escaped_character | match('[^"]')).repeat(0).as(:string_double_quotes) >> str('"')
|
22
|
+
end
|
23
|
+
|
24
|
+
rule(:string_single_quotes) do
|
25
|
+
str("'") >> (escaped_character | match("[^']")).repeat(0).as(:string_single_quotes) >> str("'")
|
26
|
+
end
|
27
|
+
|
28
|
+
rule(:string_no_quotes) do
|
29
|
+
match('[^\s]').repeat(1).as(:string_no_quotes)
|
30
|
+
end
|
31
|
+
|
32
|
+
rule(:string) do
|
33
|
+
string_double_quotes | string_single_quotes | string_no_quotes
|
34
|
+
end
|
35
|
+
|
36
|
+
# Terms
|
37
|
+
|
38
|
+
rule(:term) { string.as(:term) }
|
39
|
+
rule(:term_pair) { term.as(:left) >> space >> term_node.as(:right) }
|
40
|
+
rule(:term_node) { term_pair | term }
|
41
|
+
rule(:terms) { space? >> term_node >> space? }
|
42
|
+
root(:terms)
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Provides an interface for declaring which attributes should be searched in a term query, and a method for applying
|
5
|
+
# a term query to an `ActiveRecord::Relation` instance.
|
6
|
+
#
|
7
|
+
# @see Pursuit::TermParser
|
8
|
+
# @see Pursuit::TermTransform
|
9
|
+
#
|
10
|
+
class TermSearch
|
11
|
+
# @return [Set<Arel::Attributes::Attribute>] The attributes to match against.
|
12
|
+
#
|
13
|
+
attr_accessor :attributes
|
14
|
+
|
15
|
+
# @return [Arel::Table] The default table to retrieve attributes from.
|
16
|
+
#
|
17
|
+
attr_accessor :default_table
|
18
|
+
|
19
|
+
# Creates a new term search instance.
|
20
|
+
#
|
21
|
+
# @param default_table [Arel::Table] The default table to retrieve attributes from.
|
22
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
23
|
+
#
|
24
|
+
def initialize(default_table: nil, &block)
|
25
|
+
@attributes = Set.new
|
26
|
+
@default_table = default_table
|
27
|
+
|
28
|
+
instance_eval(&block) if block
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [Pursuit::TermParser] The parser which converts queries into trees.
|
32
|
+
#
|
33
|
+
def parser
|
34
|
+
@parser ||= TermParser.new
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Pursuit::TermTransform] The transform which converts trees into ARel nodes.
|
38
|
+
#
|
39
|
+
def transform
|
40
|
+
@transform ||= TermTransform.new
|
41
|
+
end
|
42
|
+
|
43
|
+
# Adds an attribute to match against in term queries.
|
44
|
+
#
|
45
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
46
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
47
|
+
#
|
48
|
+
def search_attribute(attribute)
|
49
|
+
attribute = default_table[attribute] if attribute.is_a?(Symbol)
|
50
|
+
attributes.add(attribute)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Parse a term query into an ARel node.
|
54
|
+
#
|
55
|
+
# @param query [String] The term query.
|
56
|
+
# @return [Arel::Nodes::Node] The ARel node representing the term query.
|
57
|
+
#
|
58
|
+
def parse(query)
|
59
|
+
tree = parser.parse(query)
|
60
|
+
transform.apply(tree, attributes: attributes)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Applies the term clauses derived from `query` to `relation`.
|
64
|
+
#
|
65
|
+
# @param query [String] The term query.
|
66
|
+
# @param relation [ActiveRecord::Relation] The base relation to apply the term clauses to.
|
67
|
+
# @return [ActiveRecord::Relation] The base relation with the term clauses applied.
|
68
|
+
#
|
69
|
+
def apply(query, relation)
|
70
|
+
node = parse(query)
|
71
|
+
node ? relation.where(node) : relation.none
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|