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.
- checksums.yaml +4 -4
- data/.github/workflows/rubygem.yaml +46 -0
- data/.ruby-version +1 -1
- data/Gemfile +15 -0
- data/Gemfile.lock +127 -86
- data/LICENSE +174 -21
- data/README.md +210 -27
- data/bin/console +10 -0
- data/config.ru +2 -3
- 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 +19 -5
- data/pursuit.gemspec +5 -18
- 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
- data/spec/spec_helper.rb +2 -3
- data/travis/gemfiles/{5.2.gemfile → 7.1.gemfile} +2 -2
- metadata +38 -197
- data/.travis.yml +0 -25
- 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/6.0.gemfile +0 -8
- data/travis/gemfiles/6.1.gemfile +0 -8
- data/travis/gemfiles/7.0.gemfile +0 -8
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Pursuit
|
2
2
|
|
3
|
-
|
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
|
-
|
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
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
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
|
-
|
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
@@ -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
|
data/lib/pursuit/constants.rb
CHANGED
@@ -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
|