pursuit 0.4.5 → 1.0.1
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 +83 -0
- data/lib/pursuit/predicate_transform.rb +231 -0
- data/lib/pursuit/query_error.rb +7 -0
- data/lib/pursuit/simple_search.rb +64 -0
- data/lib/pursuit/term_parser.rb +44 -0
- data/lib/pursuit/term_search.rb +69 -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 +25 -9
- data/spec/internal/app/models/product_category.rb +23 -1
- data/spec/internal/app/models/product_variation.rb +26 -1
- data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
- data/spec/lib/pursuit/predicate_search_spec.rb +80 -0
- data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
- data/spec/lib/pursuit/simple_search_spec.rb +59 -0
- data/spec/lib/pursuit/term_parser_spec.rb +271 -0
- data/spec/lib/pursuit/term_search_spec.rb +71 -0
- data/spec/lib/pursuit/term_transform_spec.rb +105 -0
- metadata +47 -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-based queries.
|
5
|
+
#
|
6
|
+
# Predicate-based queries take an attribute to compare the value of, an operator (such as the equal sign), and the
|
7
|
+
# value to compare with.
|
8
|
+
#
|
9
|
+
# For example, to search for records where the `first_name` attribute is equal to "John" and the `last_name`
|
10
|
+
# attribute contains either "Doe" or "Smith", you would enter:
|
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,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# :nodoc:
|
5
|
+
#
|
6
|
+
class PredicateSearch
|
7
|
+
# @return [Boolean] `true` when aggregate modifiers can be used in queries, `false` otherwise.
|
8
|
+
#
|
9
|
+
attr_accessor :permit_aggregate_modifiers
|
10
|
+
|
11
|
+
# @return [Hash<Symbol, Arel::Attributes::Attribute>] The attributes permitted for use in queries.
|
12
|
+
#
|
13
|
+
attr_reader :permitted_attributes
|
14
|
+
|
15
|
+
# @return [ActiveRecord::Relation] The relation to which the predicate clauses are added.
|
16
|
+
#
|
17
|
+
attr_reader :relation
|
18
|
+
|
19
|
+
# Creates a new predicate search instance.
|
20
|
+
#
|
21
|
+
# @param relation [ActiveRecord::Relation] The relation to which the predicate clauses are added.
|
22
|
+
# @param permit_aggregate_modifiers [Boolean] Whether aggregate modifiers can be used or not.
|
23
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
24
|
+
#
|
25
|
+
def initialize(relation, permit_aggregate_modifiers: false, &block)
|
26
|
+
@relation = relation
|
27
|
+
@permit_aggregate_modifiers = permit_aggregate_modifiers
|
28
|
+
@permitted_attributes = HashWithIndifferentAccess.new
|
29
|
+
|
30
|
+
instance_eval(&block) if block
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Pursuit::PredicateParser] The parser which converts queries into trees.
|
34
|
+
#
|
35
|
+
def parser
|
36
|
+
@parser ||= PredicateParser.new
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Pursuit::PredicateTransform] The transform which converts trees into ARel nodes.
|
40
|
+
#
|
41
|
+
def transform
|
42
|
+
@transform ||= PredicateTransform.new
|
43
|
+
end
|
44
|
+
|
45
|
+
# Permits use of the specified attribute in predicate queries.
|
46
|
+
#
|
47
|
+
# @param name [Symbol] The name used in the query.
|
48
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
49
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
50
|
+
#
|
51
|
+
def permit_attribute(name, attribute = nil)
|
52
|
+
attribute = relation.klass.arel_table[attribute] if attribute.is_a?(Symbol)
|
53
|
+
permitted_attributes[name] = attribute || relation.klass.arel_table[name]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Parse a predicate query into ARel nodes.
|
57
|
+
#
|
58
|
+
# @param query [String] The predicate query.
|
59
|
+
# @return [Hash<Symbol, Arel::Nodes::Node>] The ARel nodes representing the predicate query.
|
60
|
+
#
|
61
|
+
def parse(query)
|
62
|
+
tree = parser.parse(query)
|
63
|
+
transform.apply(
|
64
|
+
tree,
|
65
|
+
permitted_attributes: permitted_attributes,
|
66
|
+
permit_aggregate_modifiers: permit_aggregate_modifiers
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns #relation filtered by the predicate query.
|
71
|
+
#
|
72
|
+
# @param query [String] The predicate query.
|
73
|
+
# @return [ActiveRecord::Relation] The updated relation with the predicate clauses added.
|
74
|
+
#
|
75
|
+
def apply(query)
|
76
|
+
nodes = parse(query)
|
77
|
+
relation = self.relation
|
78
|
+
relation = relation.where(nodes[:where]) if nodes[:where]
|
79
|
+
relation = relation.having(nodes[:having]) if nodes[:having]
|
80
|
+
relation
|
81
|
+
end
|
82
|
+
end
|
83
|
+
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,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# :nodoc:
|
5
|
+
#
|
6
|
+
class SimpleSearch
|
7
|
+
# @return [Set<Arel::Attributes::Attribute>] The attributes to match against.
|
8
|
+
#
|
9
|
+
attr_reader :attributes
|
10
|
+
|
11
|
+
# @return [ActiveRecord::Relation] The relation to which the clauses are added.
|
12
|
+
#
|
13
|
+
attr_reader :relation
|
14
|
+
|
15
|
+
# Creates a new simple search instance.
|
16
|
+
#
|
17
|
+
# @param relation [ActiveRecord::Relation] The relation to which the clauses are added.
|
18
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
19
|
+
#
|
20
|
+
def initialize(relation, &block)
|
21
|
+
@attributes = Set.new
|
22
|
+
@relation = relation
|
23
|
+
|
24
|
+
instance_eval(&block) if block
|
25
|
+
end
|
26
|
+
|
27
|
+
# Adds an attribute to match against in queries.
|
28
|
+
#
|
29
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
30
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
31
|
+
#
|
32
|
+
def search_attribute(attribute)
|
33
|
+
attribute = relation.klass.arel_table[attribute] if attribute.is_a?(Symbol)
|
34
|
+
attributes.add(attribute)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Parse a simple query into an ARel node.
|
38
|
+
#
|
39
|
+
# @param query [String] The simple query.
|
40
|
+
# @return [Arel::Nodes::Node] The ARel node representing the simple query.
|
41
|
+
#
|
42
|
+
def parse(query)
|
43
|
+
value = ActiveRecord::Base.sanitize_sql_like(query)
|
44
|
+
value = "%#{value}%"
|
45
|
+
|
46
|
+
attributes.inject(nil) do |previous_node, attribute|
|
47
|
+
node = attribute.matches(value)
|
48
|
+
next node unless previous_node
|
49
|
+
|
50
|
+
previous_node.or(node)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns #relation filtered by the query.
|
55
|
+
#
|
56
|
+
# @param query [String] The simple query.
|
57
|
+
# @return [ActiveRecord::Relation] The updated relation with the clauses added.
|
58
|
+
#
|
59
|
+
def apply(query)
|
60
|
+
node = parse(query)
|
61
|
+
node ? relation.where(node) : relation.none
|
62
|
+
end
|
63
|
+
end
|
64
|
+
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,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# :nodoc:
|
5
|
+
#
|
6
|
+
class TermSearch
|
7
|
+
# @return [Set<Arel::Attributes::Attribute>] The attributes to match against.
|
8
|
+
#
|
9
|
+
attr_reader :attributes
|
10
|
+
|
11
|
+
# @return [ActiveRecord::Relation] The relation to which the term clauses are added.
|
12
|
+
#
|
13
|
+
attr_reader :relation
|
14
|
+
|
15
|
+
# Creates a new term search instance.
|
16
|
+
#
|
17
|
+
# @param relation [ActiveRecord::Relation] The relation to which the term clauses are added.
|
18
|
+
# @param block [Proc] The proc to invoke in the search instance (optional).
|
19
|
+
#
|
20
|
+
def initialize(relation, &block)
|
21
|
+
@attributes = Set.new
|
22
|
+
@relation = relation
|
23
|
+
|
24
|
+
instance_eval(&block) if block
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Pursuit::TermParser] The parser which converts queries into trees.
|
28
|
+
#
|
29
|
+
def parser
|
30
|
+
@parser ||= TermParser.new
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Pursuit::TermTransform] The transform which converts trees into ARel nodes.
|
34
|
+
#
|
35
|
+
def transform
|
36
|
+
@transform ||= TermTransform.new
|
37
|
+
end
|
38
|
+
|
39
|
+
# Adds an attribute to match against in term queries.
|
40
|
+
#
|
41
|
+
# @param attribute [Arel::Attributes::Attribute, Symbol] The underlying attribute to query.
|
42
|
+
# @return [Arel::Attributes::Attribute] The underlying attribute to query.
|
43
|
+
#
|
44
|
+
def search_attribute(attribute)
|
45
|
+
attribute = relation.klass.arel_table[attribute] if attribute.is_a?(Symbol)
|
46
|
+
attributes.add(attribute)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Parse a term query into an ARel node.
|
50
|
+
#
|
51
|
+
# @param query [String] The term query.
|
52
|
+
# @return [Arel::Nodes::Node] The ARel node representing the term query.
|
53
|
+
#
|
54
|
+
def parse(query)
|
55
|
+
tree = parser.parse(query)
|
56
|
+
transform.apply(tree, attributes: attributes)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Returns #relation filtered by the term query.
|
60
|
+
#
|
61
|
+
# @param query [String] The term query.
|
62
|
+
# @return [ActiveRecord::Relation] The updated relation with the term clauses added.
|
63
|
+
#
|
64
|
+
def apply(query)
|
65
|
+
node = parse(query)
|
66
|
+
node ? relation.where(node) : relation.none
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|