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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::SimpleSearch do
4
+ subject(:simple_search) do
5
+ described_class.new(
6
+ Product.left_outer_joins(:variations).group(:id)
7
+ ) do
8
+ search_attribute :title
9
+ search_attribute ProductVariation.arel_table[:title]
10
+ end
11
+ end
12
+
13
+ describe '#initialize' do
14
+ it 'is expected to set #relation eq `relation`' do
15
+ expect(simple_search).to have_attributes(relation: Product.left_outer_joins(:variations).group(:id))
16
+ end
17
+
18
+ it 'is expected to evaluate the passed block' do
19
+ expect(simple_search.attributes).to be_present
20
+ end
21
+ end
22
+
23
+ describe '#search_attribute' do
24
+ subject(:search_attribute) do
25
+ simple_search.search_attribute(ProductVariation.arel_table[:currency])
26
+ end
27
+
28
+ it 'is expected to add the attribute to #attributes' do
29
+ search_attribute
30
+ expect(simple_search.attributes).to include(ProductVariation.arel_table[:currency])
31
+ end
32
+ end
33
+
34
+ describe '#parse' do
35
+ subject(:parse) { simple_search.parse('Shirt') }
36
+
37
+ it 'is expected to equal the ARel node' do
38
+ expect(parse).to eq(
39
+ Product.arel_table[:title].matches('%Shirt%').or(
40
+ ProductVariation.arel_table[:title].matches('%Shirt%')
41
+ )
42
+ )
43
+ end
44
+ end
45
+
46
+ describe '#apply' do
47
+ subject(:apply) { simple_search.apply('Shirt') }
48
+
49
+ it 'is expected to equal #relation with clauses applied' do
50
+ expect(apply).to eq(
51
+ Product.left_outer_joins(:variations).group(:id).where(
52
+ Product.arel_table[:title].matches('%Shirt%').or(
53
+ ProductVariation.arel_table[:title].matches('%Shirt%')
54
+ )
55
+ )
56
+ )
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::TermParser do
4
+ subject(:parser) { described_class.new }
5
+
6
+ describe '#space' do
7
+ subject(:space) { parser.space }
8
+
9
+ context 'when parsing an empty value' do
10
+ subject(:parse) { space.parse('') }
11
+
12
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
13
+ end
14
+
15
+ context 'when parsing one or more spaces' do
16
+ subject(:parse) { space.parse(value) }
17
+
18
+ let(:value) { ' ' * rand(1..10) }
19
+
20
+ it 'is expected to eq the parsed spaces' do
21
+ expect(parse).to eq(value)
22
+ end
23
+ end
24
+ end
25
+
26
+ describe '#space?' do
27
+ subject(:space?) { parser.space? }
28
+
29
+ context 'when parsing an empty value' do
30
+ subject(:parse) { space?.parse('') }
31
+
32
+ it 'is expected to eq the empty value' do
33
+ expect(parse).to eq('')
34
+ end
35
+ end
36
+
37
+ context 'when parsing one or more spaces' do
38
+ subject(:parse) { space?.parse(value) }
39
+
40
+ let(:value) { ' ' * rand(1..10) }
41
+
42
+ it 'is expected to eq the parsed spaces' do
43
+ expect(parse).to eq(value)
44
+ end
45
+ end
46
+ end
47
+
48
+ describe '#escaped_character' do
49
+ subject(:escaped_character) { parser.escaped_character }
50
+
51
+ context 'when parsing an escaped character' do
52
+ subject(:parse) { escaped_character.parse('\\"') }
53
+
54
+ it 'is expected to eq the escaped character' do
55
+ expect(parse).to eq('\\"')
56
+ end
57
+ end
58
+
59
+ context 'when parsing an unescaped character' do
60
+ subject(:parse) { escaped_character.parse('"') }
61
+
62
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
63
+ end
64
+ end
65
+
66
+ describe '#string_double_quotes' do
67
+ subject(:string_double_quotes) { parser.string_double_quotes }
68
+
69
+ context 'when parsing a double quoted string' do
70
+ subject(:parse) { string_double_quotes.parse('"Double \"Quoted\""') }
71
+
72
+ it 'is expected to capture the contents of the double quotes as :string_double_quotes' do
73
+ expect(parse).to eq(string_double_quotes: 'Double \"Quoted\"')
74
+ end
75
+ end
76
+
77
+ context 'when parsing a single quoted string' do
78
+ subject(:parse) { string_double_quotes.parse("'Single Quoted'") }
79
+
80
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
81
+ end
82
+
83
+ context 'when parsing an unquoted string' do
84
+ subject(:parse) { string_double_quotes.parse('Unquoted') }
85
+
86
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
87
+ end
88
+ end
89
+
90
+ describe '#string_single_quotes' do
91
+ subject(:string_single_quotes) { parser.string_single_quotes }
92
+
93
+ context 'when parsing a single quoted string' do
94
+ subject(:parse) { string_single_quotes.parse("'Single \\'Quoted\\''") }
95
+
96
+ it 'is expected to capture the contents of the single quotes as :string_single_quotes' do
97
+ expect(parse).to eq(string_single_quotes: "Single \\'Quoted\\'")
98
+ end
99
+ end
100
+
101
+ context 'when parsing a double quoted string' do
102
+ subject(:parse) { string_single_quotes.parse('"Double Quoted"') }
103
+
104
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
105
+ end
106
+
107
+ context 'when parsing an unquoted string' do
108
+ subject(:parse) { string_single_quotes.parse('Unquoted') }
109
+
110
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
111
+ end
112
+ end
113
+
114
+ describe '#string_no_quotes' do
115
+ subject(:string_no_quotes) { parser.string_no_quotes }
116
+
117
+ context 'when parsing an unquoted string with no spaces or symbols' do
118
+ subject(:parse) { string_no_quotes.parse('Unquoted') }
119
+
120
+ it 'is expected to eq the parsed value' do
121
+ expect(parse).to eq(string_no_quotes: 'Unquoted')
122
+ end
123
+ end
124
+
125
+ context 'when parsing an unquoted string with spaces' do
126
+ subject(:parse) { string_no_quotes.parse('No Quotes') }
127
+
128
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
129
+ end
130
+
131
+ context 'when parsing an unquoted string with symbols' do
132
+ subject(:parse) { string_no_quotes.parse('No:Quotes') }
133
+
134
+ it 'is expected to eq the parsed value' do
135
+ expect(parse).to eq(string_no_quotes: 'No:Quotes')
136
+ end
137
+ end
138
+ end
139
+
140
+ describe '#string' do
141
+ subject(:string) { parser.string }
142
+
143
+ context 'when parsing a double quoted string' do
144
+ subject(:parse) { string.parse('"Double \"Quoted\""') }
145
+
146
+ it 'is expected to capture the contents of the double quotes as :string_double_quotes' do
147
+ expect(parse).to eq(string_double_quotes: 'Double \"Quoted\"')
148
+ end
149
+ end
150
+
151
+ context 'when parsing a single quoted string' do
152
+ subject(:parse) { string.parse("'Single \\'Quoted\\''") }
153
+
154
+ it 'is expected to capture the contents of the single quotes as :string_single_quotes' do
155
+ expect(parse).to eq(string_single_quotes: "Single \\'Quoted\\'")
156
+ end
157
+ end
158
+
159
+ context 'when parsing an unquoted string' do
160
+ subject(:parse) { string.parse('Unquoted') }
161
+
162
+ it 'is expected to eq the parsed value' do
163
+ expect(parse).to eq(string_no_quotes: 'Unquoted')
164
+ end
165
+ end
166
+
167
+ context 'when parsing an unquoted string with spaces' do
168
+ subject(:parse) { string.parse('No Quotes') }
169
+
170
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
171
+ end
172
+
173
+ context 'when parsing an unquoted string with symbols' do
174
+ subject(:parse) { string.parse('a@b.c') }
175
+
176
+ it 'is expected to eq the parsed value' do
177
+ expect(parse).to eq(string_no_quotes: 'a@b.c')
178
+ end
179
+ end
180
+ end
181
+
182
+ describe '#term' do
183
+ subject(:term) { parser.term }
184
+
185
+ context 'when parsing a string' do
186
+ subject(:parse) { term.parse('Hello') }
187
+
188
+ it 'is expected to capture the value as :term' do
189
+ expect(parse).to match(hash_including(term: { string_no_quotes: 'Hello' }))
190
+ end
191
+ end
192
+ end
193
+
194
+ describe '#term_pair' do
195
+ subject(:term_pair) { parser.term_pair }
196
+
197
+ context 'when parsing a term' do
198
+ subject(:parse) { term_pair.parse('Hello') }
199
+
200
+ it { expect { parse }.to raise_exception(Parslet::ParseFailed) }
201
+ end
202
+
203
+ context 'when parsing two terms separated by a space' do
204
+ subject(:parse) { term_pair.parse('Hello World') }
205
+
206
+ it 'is expected to capture the first term as :left' do
207
+ expect(parse).to match(hash_including(left: { term: { string_no_quotes: 'Hello' } }))
208
+ end
209
+
210
+ it 'is expected to capture the second term as :right' do
211
+ expect(parse).to match(hash_including(right: { term: { string_no_quotes: 'World' } }))
212
+ end
213
+ end
214
+ end
215
+
216
+ describe '#term_node' do
217
+ subject(:term_node) { parser.term_node }
218
+
219
+ context 'when parsing a term' do
220
+ subject(:parse) { term_node.parse('Hello') }
221
+
222
+ it 'is expected to eq the term result' do
223
+ expect(parse).to match(hash_including(term: { string_no_quotes: 'Hello' }))
224
+ end
225
+ end
226
+
227
+ context 'when parsing multiple terms' do
228
+ subject(:parse) { term_node.parse('Hello World') }
229
+
230
+ it 'is expected to eq the term list result' do
231
+ expect(parse).to match(
232
+ hash_including(
233
+ left: { term: { string_no_quotes: 'Hello' } },
234
+ right: { term: { string_no_quotes: 'World' } }
235
+ )
236
+ )
237
+ end
238
+ end
239
+ end
240
+
241
+ describe '#terms' do
242
+ subject(:terms) { parser.terms }
243
+
244
+ context 'when parsing a complex list of terms' do
245
+ subject(:parse) { terms.parse(" FirstTerm \"Second Term\" Third<>Term 'Fourth Term' ") }
246
+
247
+ it 'is expected to eq the correct term tree' do
248
+ expect(parse).to match(
249
+ hash_including(
250
+ left: { term: { string_no_quotes: 'FirstTerm' } },
251
+ right: {
252
+ left: { term: { string_double_quotes: 'Second Term' } },
253
+ right: {
254
+ left: { term: { string_no_quotes: 'Third<>Term' } },
255
+ right: { term: { string_single_quotes: 'Fourth Term' } }
256
+ }
257
+ }
258
+ )
259
+ )
260
+ end
261
+ end
262
+ end
263
+
264
+ describe '#root' do
265
+ subject(:root) { parser.root }
266
+
267
+ it 'is expected to eq #terms' do
268
+ expect(root).to eq(parser.terms)
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::TermSearch do
4
+ subject(:term_search) do
5
+ described_class.new(
6
+ Product.left_outer_joins(:variations).group(:id)
7
+ ) do
8
+ search_attribute :title
9
+ search_attribute ProductVariation.arel_table[:title]
10
+ end
11
+ end
12
+
13
+ describe '#initialize' do
14
+ it 'is expected to set #relation eq `relation`' do
15
+ expect(term_search).to have_attributes(relation: Product.left_outer_joins(:variations).group(:id))
16
+ end
17
+
18
+ it 'is expected to evaluate the passed block' do
19
+ expect(term_search.attributes).to be_present
20
+ end
21
+ end
22
+
23
+ describe '#parser' do
24
+ subject(:parser) { term_search.parser }
25
+
26
+ it { is_expected.to be_a(Pursuit::TermParser) }
27
+ end
28
+
29
+ describe '#transform' do
30
+ subject(:transform) { term_search.transform }
31
+
32
+ it { is_expected.to be_a(Pursuit::TermTransform) }
33
+ end
34
+
35
+ describe '#search_attribute' do
36
+ subject(:search_attribute) do
37
+ term_search.search_attribute(ProductVariation.arel_table[:currency])
38
+ end
39
+
40
+ it 'is expected to add the attribute to #attributes' do
41
+ search_attribute
42
+ expect(term_search.attributes).to include(ProductVariation.arel_table[:currency])
43
+ end
44
+ end
45
+
46
+ describe '#parse' do
47
+ subject(:parse) { term_search.parse('Shirt') }
48
+
49
+ it 'is expected to equal the ARel node' do
50
+ expect(parse).to eq(
51
+ Product.arel_table[:title].matches('%Shirt%').or(
52
+ ProductVariation.arel_table[:title].matches('%Shirt%')
53
+ )
54
+ )
55
+ end
56
+ end
57
+
58
+ describe '#apply' do
59
+ subject(:apply) { term_search.apply('Shirt') }
60
+
61
+ it 'is expected to equal #relation with term clauses applied' do
62
+ expect(apply).to eq(
63
+ Product.left_outer_joins(:variations).group(:id).where(
64
+ Product.arel_table[:title].matches('%Shirt%').or(
65
+ ProductVariation.arel_table[:title].matches('%Shirt%')
66
+ )
67
+ )
68
+ )
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::TermTransform do
4
+ subject(:transform) { described_class.new }
5
+
6
+ describe '#apply' do
7
+ subject(:apply) { transform.apply(tree, attributes: attributes) }
8
+
9
+ let(:attributes) do
10
+ [
11
+ Product.arel_table[:title],
12
+ ProductVariation.arel_table[:title]
13
+ ].to_set
14
+ end
15
+
16
+ context 'when passed an empty double quoted string' do
17
+ let(:tree) do
18
+ { string_double_quotes: [] }
19
+ end
20
+
21
+ it 'is expected to equal an empty string' do
22
+ expect(apply).to eq('')
23
+ end
24
+ end
25
+
26
+ context 'when passed a double quoted string' do
27
+ let(:tree) do
28
+ { string_double_quotes: 'Hello \\"World\\"' }
29
+ end
30
+
31
+ it 'is expected to equal the correct string' do
32
+ expect(apply).to eq('Hello "World"')
33
+ end
34
+ end
35
+
36
+ context 'when passed an empty single quoted string' do
37
+ let(:tree) do
38
+ { string_single_quotes: [] }
39
+ end
40
+
41
+ it 'is expected to equal an empty string' do
42
+ expect(apply).to eq('')
43
+ end
44
+ end
45
+
46
+ context 'when passed a single quoted string' do
47
+ let(:tree) do
48
+ { string_single_quotes: "Hello \\'World\\'" }
49
+ end
50
+
51
+ it 'is expected to equal the correct string' do
52
+ expect(apply).to eq("Hello 'World'")
53
+ end
54
+ end
55
+
56
+ context 'when passed an unquoted string' do
57
+ let(:tree) do
58
+ { string_no_quotes: 'hello_world' }
59
+ end
60
+
61
+ it 'is expected to equal the correct string' do
62
+ expect(apply).to eq('hello_world')
63
+ end
64
+ end
65
+
66
+ context 'when passed a term' do
67
+ let(:tree) do
68
+ { term: { string_no_quotes: 'Shirt' } }
69
+ end
70
+
71
+ it 'is expected to equal the correct ARel node' do
72
+ expect(apply).to eq(
73
+ Product.arel_table[:title].matches('%Shirt%').or(
74
+ ProductVariation.arel_table[:title].matches('%Shirt%')
75
+ )
76
+ )
77
+ end
78
+ end
79
+
80
+ context 'when passed multiple terms' do
81
+ let(:tree) do
82
+ {
83
+ left: {
84
+ term: { string_no_quotes: 'Shirt' }
85
+ },
86
+ right: {
87
+ term: { string_no_quotes: 'Green' }
88
+ }
89
+ }
90
+ end
91
+
92
+ it 'is expected to equal the correct ARel node' do
93
+ expect(apply).to eq(
94
+ Product.arel_table[:title].matches('%Shirt%').or(
95
+ ProductVariation.arel_table[:title].matches('%Shirt%')
96
+ ).and(
97
+ Product.arel_table[:title].matches('%Green%').or(
98
+ ProductVariation.arel_table[:title].matches('%Green%')
99
+ )
100
+ )
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
data/spec/spec_helper.rb CHANGED
@@ -13,13 +13,12 @@
13
13
  # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
14
14
 
15
15
  require 'combustion'
16
- Combustion.initialize! :active_record
16
+ Combustion.initialize!(:active_record)
17
17
 
18
18
  require 'bundler'
19
- Bundler.require :default, :development
19
+ Bundler.require(:default, :development)
20
20
 
21
21
  require 'rspec/rails'
22
-
23
22
  RSpec.configure do |config|
24
23
  # rspec-expectations config goes here. You can use an alternate assertion/expectation library such as wrong or the
25
24
  # stdlib/minitest assertions if you prefer.
@@ -4,5 +4,5 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec path: '../../'
6
6
 
7
- gem 'activerecord', '~> 5.2.0'
8
- gem 'activesupport', '~> 5.2.0'
7
+ gem 'activerecord', '~> 7.1.0'
8
+ gem 'activesupport', '~> 7.1.0'