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.
Files changed (51) 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 +86 -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 +66 -0
  18. data/lib/pursuit/term_parser.rb +44 -0
  19. data/lib/pursuit/term_search.rb +74 -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 +3 -14
  25. data/spec/internal/app/models/product_category.rb +3 -1
  26. data/spec/internal/app/models/product_variation.rb +3 -1
  27. data/spec/internal/app/searches/product_category_search.rb +50 -0
  28. data/spec/internal/app/searches/product_search.rb +53 -0
  29. data/spec/internal/app/searches/product_variation_search.rb +53 -0
  30. data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
  31. data/spec/lib/pursuit/predicate_search_spec.rb +116 -0
  32. data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
  33. data/spec/lib/pursuit/simple_search_spec.rb +73 -0
  34. data/spec/lib/pursuit/term_parser_spec.rb +271 -0
  35. data/spec/lib/pursuit/term_search_spec.rb +85 -0
  36. data/spec/lib/pursuit/term_transform_spec.rb +105 -0
  37. metadata +50 -25
  38. data/.travis.yml +0 -26
  39. data/lib/pursuit/dsl.rb +0 -28
  40. data/lib/pursuit/railtie.rb +0 -13
  41. data/lib/pursuit/search.rb +0 -172
  42. data/lib/pursuit/search_options.rb +0 -86
  43. data/lib/pursuit/search_term_parser.rb +0 -46
  44. data/spec/lib/pursuit/dsl_spec.rb +0 -22
  45. data/spec/lib/pursuit/search_options_spec.rb +0 -146
  46. data/spec/lib/pursuit/search_spec.rb +0 -516
  47. data/spec/lib/pursuit/search_term_parser_spec.rb +0 -34
  48. data/travis/gemfiles/5.2.gemfile +0 -8
  49. data/travis/gemfiles/6.0.gemfile +0 -8
  50. data/travis/gemfiles/6.1.gemfile +0 -8
  51. 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,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,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