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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubygem.yaml +46 -0
  3. data/Gemfile +14 -13
  4. data/Gemfile.lock +14 -13
  5. data/README.md +210 -27
  6. data/bin/console +10 -0
  7. data/lib/pursuit/aggregate_modifier_not_found.rb +20 -0
  8. data/lib/pursuit/aggregate_modifier_required.rb +20 -0
  9. data/lib/pursuit/aggregate_modifiers_not_available.rb +13 -0
  10. data/lib/pursuit/attribute_not_found.rb +20 -0
  11. data/lib/pursuit/constants.rb +1 -1
  12. data/lib/pursuit/error.rb +7 -0
  13. data/lib/pursuit/predicate_parser.rb +181 -0
  14. data/lib/pursuit/predicate_search.rb +83 -0
  15. data/lib/pursuit/predicate_transform.rb +231 -0
  16. data/lib/pursuit/query_error.rb +7 -0
  17. data/lib/pursuit/simple_search.rb +64 -0
  18. data/lib/pursuit/term_parser.rb +44 -0
  19. data/lib/pursuit/term_search.rb +69 -0
  20. data/lib/pursuit/term_transform.rb +35 -0
  21. data/lib/pursuit.rb +18 -4
  22. data/pursuit.gemspec +4 -3
  23. data/spec/internal/app/models/application_record.rb +5 -0
  24. data/spec/internal/app/models/product.rb +25 -9
  25. data/spec/internal/app/models/product_category.rb +23 -1
  26. data/spec/internal/app/models/product_variation.rb +26 -1
  27. data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
  28. data/spec/lib/pursuit/predicate_search_spec.rb +80 -0
  29. data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
  30. data/spec/lib/pursuit/simple_search_spec.rb +59 -0
  31. data/spec/lib/pursuit/term_parser_spec.rb +271 -0
  32. data/spec/lib/pursuit/term_search_spec.rb +71 -0
  33. data/spec/lib/pursuit/term_transform_spec.rb +105 -0
  34. metadata +47 -25
  35. data/.travis.yml +0 -26
  36. data/lib/pursuit/dsl.rb +0 -28
  37. data/lib/pursuit/railtie.rb +0 -13
  38. data/lib/pursuit/search.rb +0 -172
  39. data/lib/pursuit/search_options.rb +0 -86
  40. data/lib/pursuit/search_term_parser.rb +0 -46
  41. data/spec/lib/pursuit/dsl_spec.rb +0 -22
  42. data/spec/lib/pursuit/search_options_spec.rb +0 -146
  43. data/spec/lib/pursuit/search_spec.rb +0 -516
  44. data/spec/lib/pursuit/search_term_parser_spec.rb +0 -34
  45. data/travis/gemfiles/5.2.gemfile +0 -8
  46. data/travis/gemfiles/6.0.gemfile +0 -8
  47. data/travis/gemfiles/6.1.gemfile +0 -8
  48. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Base error class for all query related errors.
5
+ #
6
+ class QueryError < Error; end
7
+ 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