pursuit 0.4.3 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubygem.yaml +46 -0
  3. data/.ruby-version +1 -1
  4. data/Gemfile +15 -0
  5. data/Gemfile.lock +127 -86
  6. data/LICENSE +174 -21
  7. data/README.md +210 -27
  8. data/bin/console +10 -0
  9. data/config.ru +2 -3
  10. data/lib/pursuit/aggregate_modifier_not_found.rb +20 -0
  11. data/lib/pursuit/aggregate_modifier_required.rb +20 -0
  12. data/lib/pursuit/aggregate_modifiers_not_available.rb +13 -0
  13. data/lib/pursuit/attribute_not_found.rb +20 -0
  14. data/lib/pursuit/constants.rb +1 -1
  15. data/lib/pursuit/error.rb +7 -0
  16. data/lib/pursuit/predicate_parser.rb +181 -0
  17. data/lib/pursuit/predicate_search.rb +83 -0
  18. data/lib/pursuit/predicate_transform.rb +231 -0
  19. data/lib/pursuit/query_error.rb +7 -0
  20. data/lib/pursuit/simple_search.rb +64 -0
  21. data/lib/pursuit/term_parser.rb +44 -0
  22. data/lib/pursuit/term_search.rb +69 -0
  23. data/lib/pursuit/term_transform.rb +35 -0
  24. data/lib/pursuit.rb +19 -5
  25. data/pursuit.gemspec +5 -18
  26. data/spec/internal/app/models/application_record.rb +5 -0
  27. data/spec/internal/app/models/product.rb +25 -9
  28. data/spec/internal/app/models/product_category.rb +23 -1
  29. data/spec/internal/app/models/product_variation.rb +26 -1
  30. data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
  31. data/spec/lib/pursuit/predicate_search_spec.rb +80 -0
  32. data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
  33. data/spec/lib/pursuit/simple_search_spec.rb +59 -0
  34. data/spec/lib/pursuit/term_parser_spec.rb +271 -0
  35. data/spec/lib/pursuit/term_search_spec.rb +71 -0
  36. data/spec/lib/pursuit/term_transform_spec.rb +105 -0
  37. data/spec/spec_helper.rb +2 -3
  38. data/travis/gemfiles/{5.2.gemfile → 7.1.gemfile} +2 -2
  39. metadata +38 -197
  40. data/.travis.yml +0 -25
  41. data/lib/pursuit/dsl.rb +0 -28
  42. data/lib/pursuit/railtie.rb +0 -13
  43. data/lib/pursuit/search.rb +0 -172
  44. data/lib/pursuit/search_options.rb +0 -86
  45. data/lib/pursuit/search_term_parser.rb +0 -46
  46. data/spec/lib/pursuit/dsl_spec.rb +0 -22
  47. data/spec/lib/pursuit/search_options_spec.rb +0 -146
  48. data/spec/lib/pursuit/search_spec.rb +0 -516
  49. data/spec/lib/pursuit/search_term_parser_spec.rb +0 -34
  50. data/travis/gemfiles/6.0.gemfile +0 -8
  51. data/travis/gemfiles/6.1.gemfile +0 -8
  52. data/travis/gemfiles/7.0.gemfile +0 -8
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pursuit
2
2
 
3
- Advanced key-based searching for ActiveRecord objects.
3
+ Search your ActiveRecord objects with ease!
4
4
 
5
5
  ## Installation
6
6
 
@@ -16,41 +16,224 @@ gem 'pursuit'
16
16
 
17
17
  ### Usage
18
18
 
19
- You can use the convenient DSL syntax to declare which attributes and relationships are searchable:
19
+ Pursuit comes with three different strategies for interpreting queries:
20
+
21
+ - Simple
22
+ - Term
23
+ - Predicate
24
+
25
+ ### Simple Search
26
+
27
+ Simple takes the entire query and generates a SQL `LIKE` (or `ILIKE` for *PostgreSQL*) statement for each attribute
28
+ added to the search instance. Here's an example of how you might use simple to search a hypothetical `Product` record:
29
+
30
+ ```ruby
31
+ search = Pursuit::SimpleSearch.new(Product.all)
32
+ search.search_attribute(:title)
33
+ search.search_attribute(:subtitle)
34
+ search.apply('Green Shirt')
35
+ ```
36
+
37
+ Which results in the following SQL query:
38
+
39
+ ```sql
40
+ SELECT
41
+ "products".*
42
+ FROM
43
+ "products"
44
+ WHERE
45
+ "products"."title" LIKE '%Green Shirt%'
46
+ OR "products"."subtitle" LIKE '%Green Shirt%'
47
+ ```
48
+
49
+ The initializer method also accepts a block, which is evaluated within the instance's context. This can make it cleaner
50
+ when declaring the searchable attributes:
51
+
52
+ ```ruby
53
+ search = Pursuit::SimpleSearch.new(Product.all) do
54
+ search_attribute :title
55
+ search_attribute :subtitle
56
+ end
57
+
58
+ search.apply('Green Shirt')
59
+ ```
60
+
61
+ You can also pass custom `Arel::Attribute::Attribute` objects, which are especially useful when using joins:
62
+
63
+ ```ruby
64
+ search = Pursuit::SimpleSearch.new(
65
+ Product.left_outer_joins(:variations).group(:id)
66
+ ) do
67
+ search_attribute :title
68
+ search_attribute ProductVariation.arel_table[:title]
69
+ end
70
+
71
+ search.apply('Green Shirt')
72
+ ```
73
+
74
+ Which results in the following SQL query:
75
+
76
+ ```sql
77
+ SELECT
78
+ "products".*
79
+ FROM
80
+ "products"
81
+ LEFT OUTER JOIN "product_variations" ON "product_variations"."product_id" = "products"."id"
82
+ WHERE
83
+ "products"."title" LIKE '%Green Shirt%'
84
+ OR "product_variations"."title" LIKE '%Green Shirt%'
85
+ GROUP BY
86
+ "products"."id"
87
+ ```
88
+
89
+ ### Term Search
90
+
91
+ Term searches break a query into individual terms on spaces, while providing double and single quoted strings as a
92
+ means to include spaces. Here's an example of using term searches on the same `Product` record from earlier:
20
93
 
21
94
  ```ruby
22
- class Product < ActiveRecord::Base
23
- searchable do |o|
24
- o.relation :variations, :title, :stock_status
25
-
26
- # Attributes can be used for both keyed and unkeyed searching by default, but you can pass either `keyed: false` or
27
- # `unkeyed: false` to restrict when the attribute is searched.
28
- o.attribute :title
29
- o.attribute :description
30
- o.attribute :rating, unkeyed: false
31
-
32
- # You can shorten the search keyword by passing the desired search term first, and then the real attribute name
33
- # as the second argument.
34
- # => "category*=shirts"
35
- o.attribute :category, :category_id
36
-
37
- # It's also possible to query entirely custom Arel nodes by passing a block which returns the Arel node to query.
38
- # You could use this to query a person's full name by concatenating their first and last name columns, for example.
39
- o.attribute :title_length, unkeyed: false do
40
- Arel::Nodes::NamedFunction.new('LENGTH', [
41
- arel_table[:title]
42
- ])
43
- end
44
- end
95
+ search = Pursuit::TermSearch.new(Product.all) do
96
+ search_attribute :title
97
+ search_attribute :subtitle
45
98
  end
99
+
100
+ search.apply('Green "Luxury Shirt"')
101
+ ```
102
+
103
+ Which results in a SQL query similar to the following:
104
+
105
+ ```sql
106
+ SELECT
107
+ "products".*
108
+ FROM
109
+ "products"
110
+ WHERE
111
+ (
112
+ "products"."title" LIKE '%Green%'
113
+ OR "products"."subtitle" LIKE '%Green%'
114
+ ) AND (
115
+ "products"."title" LIKE '%Luxury Shirt%'
116
+ OR "products"."subtitle" LIKE '%Luxury Shirt%'
117
+ )
46
118
  ```
47
119
 
48
- This creates a ```.search``` method on your record class which accepts a single query argument:
120
+ ### Predicate Search
121
+
122
+ Predicate searches use a parser (implemented with the `parslet` gem) to provide a minimal query language.
123
+ This syntax is similar to the `WHERE` and `HAVING` clauses in SQL, but uses only symbols for operators and joins.
124
+
125
+ Attributes can only be used in predicate searches when they have been added to the list of permitted attributes.
126
+ You can also rename attributes, and add attributes for joined records.
127
+
128
+ Here's a more complex example of using predicate-based searches with joins on the `Product` record from earlier:
49
129
 
50
130
  ```ruby
51
- Product.search('plain shirt rating>=3')
131
+ search = Pursuit::PredicateSearch.new(
132
+ Product.left_outer_join(:category, :variations).group(:id)
133
+ ) do
134
+ # Product Attributes
135
+ permit_attribute :title
136
+
137
+ # Product Category Attributes
138
+ permit_attribute :category_name, ProductCategory.arel_table[:name]
139
+
140
+ # Product Variation Attributes
141
+ permit_attribute :variation_title, ProductVariation.arel_table[:title]
142
+ permit_attribute :variation_currency, ProductVariation.arel_table[:currency]
143
+ permit_attribute :variation_amount, ProductVariation.arel_table[:amount]
144
+ end
145
+
146
+ search.apply('title = "Luxury Shirt" & (variation_amount = 0 | variation_amount > 1000)')
147
+ ```
148
+
149
+ This translates to "a product whose title is 'Luxury Shirt' and has at least one variation with either an amount of 0,
150
+ or an amount greater than 1000", which could be expressed in SQL as:
151
+
152
+ ```sql
153
+ SELECT
154
+ "products".*
155
+ FROM
156
+ "products"
157
+ LEFT OUTER JOIN "product_categories" ON "product_categories"."id" = "products"."category_id"
158
+ LEFT OUTER JOIN "product_variations" ON "product_variations"."product_id" = "products"."id"
159
+ WHERE
160
+ "products"."title" = 'Luxury Shirt'
161
+ AND (
162
+ "product_variations"."amount" = 0
163
+ OR "product_variations"."amount" > 1000
164
+ )
165
+ GROUP BY
166
+ "products"."id"
52
167
  ```
53
168
 
169
+ You can use any of the following operators in comparisons:
170
+
171
+ - `=` checks if the attribute is equal to the value.
172
+ - `!=` checks if the attributes is not equal to the value.
173
+ - `>` checks if the attribute is greater than the value.
174
+ - `<` checks if the attribute is less than the value.
175
+ - `>=` checks if the attribute is greater than or equal to the value.
176
+ - `<=` checks if the attribute is less than or equal to the value.
177
+ - `~` checks if the attribute matches the value (using `LIKE` or `ILIKE`).
178
+ - `!~` checks if the attribute does not match the value (using `LIKE` or `ILIKE`).
179
+
180
+ Predicate searches also support "aggregate modifiers" which enable the use of aggregate functions, however this feature
181
+ must be explicitly enabled and requires you to use a `GROUP BY` clause:
182
+
183
+ ```ruby
184
+ search = Pursuit::PredicateSearch.new(
185
+ Product.left_outer_join(:category, :variations).group(:id)
186
+ ) do
187
+ # Product Attributes
188
+ permit_attribute :title
189
+
190
+ # Product Category Attributes
191
+ permit_attribute :category, ProductCategory.arel_table[:id]
192
+ permit_attribute :category_name, ProductCategory.arel_table[:name]
193
+
194
+ # Product Variation Attributes
195
+ permit_attribute :variation, ProductVariation.arel_table[:id]
196
+ permit_attribute :variation_title, ProductVariation.arel_table[:title]
197
+ permit_attribute :variation_currency, ProductVariation.arel_table[:currency]
198
+ permit_attribute :variation_amount, ProductVariation.arel_table[:amount]
199
+ end
200
+
201
+ search.apply('title = "Luxury Shirt" & #variation > 5')
202
+ ```
203
+
204
+ And the resulting SQL from this query:
205
+
206
+ ```sql
207
+ SELECT
208
+ "products".*
209
+ FROM
210
+ "products"
211
+ LEFT OUTER JOIN "product_categories" ON "product_categories"."id" = "products"."category_id"
212
+ LEFT OUTER JOIN "product_variations" ON "product_variations"."product_id" = "products"."id"
213
+ WHERE
214
+ "products"."title" = 'Luxury Shirt'
215
+ GROUP BY
216
+ "products"."id"
217
+ HAVING
218
+ COUNT("product_variations"."id") > 5
219
+ ```
220
+
221
+ There's no distinction between the `WHERE` and `HAVING` clause in the predicate syntax, as it's intended to be easy to
222
+ use, but this does come with a caveat.
223
+
224
+ The query must have all aggregate-modified comparisons before or after non-aggregate-modified comparisons, you can't
225
+ mix both.
226
+
227
+ For example, this query would result in a parsing error: `title ~ Shirt & #variation > 5 & category_name = Shirts`
228
+
229
+ You can preceed any attribute with one of these aggregate modifier symbols:
230
+
231
+ - `#` uses the `COUNT` aggregate function
232
+ - `+` uses the `MAX` aggregate function
233
+ - `-` uses the `MIN` aggregate function
234
+ - `*` uses the `SUM` aggregate function
235
+ - `~` uses the `AVG` aggregate function
236
+
54
237
  ## Development
55
238
 
56
239
  After checking out the repo, run `bundle exec rake spec` to run the tests.
data/bin/console CHANGED
@@ -1,7 +1,17 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ # frozen_string_literal: true
4
+
3
5
  require 'bundler/setup'
4
6
  require 'pursuit'
5
7
  require 'pry'
6
8
 
9
+ if ENV['AR'] == 'true'
10
+ require 'combustion'
11
+ Combustion.initialize!(:active_record)
12
+
13
+ require 'bundler'
14
+ Bundler.require(:default, :development)
15
+ end
16
+
7
17
  Pry.start
data/config.ru CHANGED
@@ -3,7 +3,6 @@
3
3
  require 'rubygems'
4
4
  require 'bundler'
5
5
 
6
- Bundler.require :default, :development
7
-
8
- Combustion.initialize! :all
6
+ Bundler.require(:default, :development)
7
+ Combustion.initialize!(:all)
9
8
  run Combustion::Application
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Raised when an aggregate modifier cannot be found.
5
+ #
6
+ class AggregateModifierNotFound < QueryError
7
+ # @return [String] The aggregate modifier which does not map to an aggregate function.
8
+ #
9
+ attr_reader :aggregate_modifier
10
+
11
+ # Creates a new error instance.
12
+ #
13
+ # @param aggregate_modifier [Symbol] The aggregate modifier which does not map to an aggregate function.
14
+ #
15
+ def initialize(aggregate_modifier)
16
+ @aggregate_modifier = aggregate_modifier
17
+ super("#{aggregate_modifier} is not a valid aggregate modifier")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Raised when an attribute that must be used with an aggregate modifier is used without one.
5
+ #
6
+ class AggregateModifierRequired < QueryError
7
+ # @return [Symbol] The name of the attribute which must be used with an aggregate modifier.
8
+ #
9
+ attr_reader :attribute
10
+
11
+ # Creates a new error instance.
12
+ #
13
+ # @param attribute [Symbol] The name of the attribute which must be used with an aggregate modifier.
14
+ #
15
+ def initialize(attribute)
16
+ @attribute = attribute
17
+ super("'#{attribute}' must be used with an aggregate modifier")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Raised when an aggregate modifier is used in a query, but aggregate modifiers are not available.
5
+ #
6
+ class AggregateModifiersNotAvailable < QueryError
7
+ # Creates a new error instance.
8
+ #
9
+ def initialize
10
+ super('Aggregate modifiers cannot be used in this query')
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Raised when an attribute cannot be found.
5
+ #
6
+ class AttributeNotFound < QueryError
7
+ # @return [Symbol] The name of the attribute which could not be found.
8
+ #
9
+ attr_reader :attribute
10
+
11
+ # Creates a new error instance.
12
+ #
13
+ # @param attribute [Symbol] The name of the attribute which could not be found.
14
+ #
15
+ def initialize(attribute)
16
+ @attribute = attribute
17
+ super("'#{attribute}' is not a valid attribute")
18
+ end
19
+ end
20
+ end
@@ -3,5 +3,5 @@
3
3
  module Pursuit
4
4
  # @return [String] The gem's semantic version number.
5
5
  #
6
- VERSION = '0.4.3'
6
+ VERSION = '1.0.1'
7
7
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ # Base error class for all pursuit errors.
5
+ #
6
+ class Error < StandardError; end
7
+ end
@@ -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