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
@@ -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,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
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
# Transform for a list of terms.
|
5
|
+
#
|
6
|
+
class TermTransform < Parslet::Transform
|
7
|
+
# String Types
|
8
|
+
|
9
|
+
rule(string_double_quotes: []) { '' }
|
10
|
+
rule(string_double_quotes: simple(:value)) { value.to_s.gsub(/\\(.)/, '\1') }
|
11
|
+
|
12
|
+
rule(string_single_quotes: []) { '' }
|
13
|
+
rule(string_single_quotes: simple(:value)) { value.to_s.gsub(/\\(.)/, '\1') }
|
14
|
+
|
15
|
+
rule(string_no_quotes: simple(:value)) { value.to_s }
|
16
|
+
|
17
|
+
# Terms
|
18
|
+
|
19
|
+
rule(term: simple(:term)) do |context|
|
20
|
+
value = ActiveRecord::Base.sanitize_sql_like(context[:term])
|
21
|
+
value = "%#{value}%"
|
22
|
+
|
23
|
+
context[:attributes].inject(nil) do |previous_node, attribute|
|
24
|
+
node = attribute.matches(value)
|
25
|
+
next node unless previous_node
|
26
|
+
|
27
|
+
previous_node.or(node)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Joins
|
32
|
+
|
33
|
+
rule(left: simple(:left), right: simple(:right)) { left.and(right) }
|
34
|
+
end
|
35
|
+
end
|
data/lib/pursuit.rb
CHANGED
@@ -1,7 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
4
|
-
require '
|
5
|
-
require '
|
6
|
-
require '
|
7
|
-
|
3
|
+
require 'active_record'
|
4
|
+
require 'active_support'
|
5
|
+
require 'bigdecimal'
|
6
|
+
require 'parslet'
|
7
|
+
|
8
|
+
require_relative 'pursuit/constants'
|
9
|
+
require_relative 'pursuit/error'
|
10
|
+
require_relative 'pursuit/query_error'
|
11
|
+
require_relative 'pursuit/aggregate_modifier_not_found'
|
12
|
+
require_relative 'pursuit/aggregate_modifier_required'
|
13
|
+
require_relative 'pursuit/aggregate_modifiers_not_available'
|
14
|
+
require_relative 'pursuit/attribute_not_found'
|
15
|
+
require_relative 'pursuit/predicate_parser'
|
16
|
+
require_relative 'pursuit/predicate_transform'
|
17
|
+
require_relative 'pursuit/predicate_search'
|
18
|
+
require_relative 'pursuit/term_parser'
|
19
|
+
require_relative 'pursuit/term_transform'
|
20
|
+
require_relative 'pursuit/term_search'
|
21
|
+
require_relative 'pursuit/simple_search'
|
data/pursuit.gemspec
CHANGED
@@ -12,8 +12,8 @@ Gem::Specification.new do |spec|
|
|
12
12
|
spec.email = ['support@nialtoservices.co.uk']
|
13
13
|
|
14
14
|
spec.summary = 'Advanced key-based searching for ActiveRecord objects.'
|
15
|
-
spec.homepage = 'https://github.com/
|
16
|
-
spec.license = '
|
15
|
+
spec.homepage = 'https://github.com/NialtoServices/pursuit'
|
16
|
+
spec.license = 'Apache-2.0'
|
17
17
|
|
18
18
|
spec.files = `git ls-files -z`.split("\x0")
|
19
19
|
spec.require_paths = ['lib']
|
@@ -23,20 +23,7 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
24
24
|
spec.metadata['yard.run'] = 'yri'
|
25
25
|
|
26
|
-
spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '
|
27
|
-
spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '
|
28
|
-
|
29
|
-
spec.add_development_dependency 'bundler', '~> 2.0'
|
30
|
-
spec.add_development_dependency 'combustion', '~> 1.3'
|
31
|
-
spec.add_development_dependency 'guard', '~> 2.18'
|
32
|
-
spec.add_development_dependency 'guard-rspec', '~> 4.7'
|
33
|
-
spec.add_development_dependency 'pry', '~> 0.14'
|
34
|
-
spec.add_development_dependency 'rake', '~> 13.0'
|
35
|
-
spec.add_development_dependency 'rspec', '~> 3.12'
|
36
|
-
spec.add_development_dependency 'rspec-rails', '~> 6.0'
|
37
|
-
spec.add_development_dependency 'rubocop', '~> 1.44'
|
38
|
-
spec.add_development_dependency 'rubocop-rake', '~> 0.6'
|
39
|
-
spec.add_development_dependency 'rubocop-rspec', '~> 2.18'
|
40
|
-
spec.add_development_dependency 'sqlite3', '~> 1.6'
|
41
|
-
spec.add_development_dependency 'yard', '~> 0.9'
|
26
|
+
spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '<= 8.0.0'
|
27
|
+
spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '<= 8.0.0'
|
28
|
+
spec.add_runtime_dependency 'parslet', '~> 2.0'
|
42
29
|
end
|
@@ -1,22 +1,38 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
class Product <
|
3
|
+
class Product < ApplicationRecord
|
4
4
|
belongs_to :category, class_name: 'ProductCategory', inverse_of: :products, optional: true
|
5
5
|
|
6
6
|
has_many :variations, class_name: 'ProductVariation', inverse_of: :product
|
7
7
|
|
8
8
|
validates :title, presence: true
|
9
9
|
|
10
|
-
|
11
|
-
|
10
|
+
def self.predicate_search
|
11
|
+
@predicate_search ||= Pursuit::PredicateSearch.new(
|
12
|
+
left_outer_joins(:category, :variations).group(:id).order(:title)
|
13
|
+
) do
|
14
|
+
permit_attribute :title
|
15
|
+
permit_attribute :category, ProductCategory.arel_table[:id]
|
16
|
+
permit_attribute :category_name, ProductCategory.arel_table[:name]
|
17
|
+
permit_attribute :variation, ProductVariation.arel_table[:id]
|
18
|
+
permit_attribute :variation_title, ProductVariation.arel_table[:title]
|
19
|
+
permit_attribute :variation_currency, ProductVariation.arel_table[:currency]
|
20
|
+
permit_attribute :variation_amount, ProductVariation.arel_table[:amount]
|
21
|
+
end
|
22
|
+
end
|
12
23
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
24
|
+
def self.term_search
|
25
|
+
@term_search ||= Pursuit::TermSearch.new(
|
26
|
+
left_outer_joins(:category).group(:id).order(:title)
|
27
|
+
) do
|
28
|
+
search_attribute :title
|
29
|
+
search_attribute ProductCategory.arel_table[:name]
|
18
30
|
end
|
31
|
+
end
|
19
32
|
|
20
|
-
|
33
|
+
def self.search(query)
|
34
|
+
predicate_search.apply(query)
|
35
|
+
rescue Parslet::ParseFailed
|
36
|
+
term_search.apply(query)
|
21
37
|
end
|
22
38
|
end
|
@@ -1,7 +1,29 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
class ProductCategory <
|
3
|
+
class ProductCategory < ApplicationRecord
|
4
4
|
has_many :products, class_name: 'Product', foreign_key: :category_id, inverse_of: :category, dependent: :nullify
|
5
5
|
|
6
6
|
validates :name, presence: true
|
7
|
+
|
8
|
+
def self.predicate_search
|
9
|
+
@predicate_search ||= Pursuit::PredicateSearch.new(
|
10
|
+
left_outer_joins(:products).group(:id)
|
11
|
+
) do
|
12
|
+
permit_attribute :name
|
13
|
+
permit_attribute :product, Product.arel_table[:id]
|
14
|
+
permit_attribute :product_title, Product.arel_table[:title]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.term_search
|
19
|
+
@term_search ||= Pursuit::TermSearch.new(all) do
|
20
|
+
search_attribute :name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.search(query)
|
25
|
+
predicate_search.apply(query)
|
26
|
+
rescue Parslet::ParseFailed
|
27
|
+
term_search.apply(query)
|
28
|
+
end
|
7
29
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
class ProductVariation <
|
3
|
+
class ProductVariation < ApplicationRecord
|
4
4
|
belongs_to :product
|
5
5
|
|
6
6
|
enum stock_status: { in_stock: 1, low_stock: 2, out_of_stock: 3 }
|
@@ -9,4 +9,29 @@ class ProductVariation < ActiveRecord::Base
|
|
9
9
|
|
10
10
|
validates :currency, presence: true
|
11
11
|
validates :amount, presence: true, numericality: true
|
12
|
+
|
13
|
+
def self.predicate_search
|
14
|
+
@predicate_search ||= Pursuit::PredicateSearch.new(
|
15
|
+
left_outer_joins(:product).group(:id)
|
16
|
+
) do
|
17
|
+
permit_attribute :title
|
18
|
+
permit_attribute :stock_status
|
19
|
+
permit_attribute :currency
|
20
|
+
permit_attribute :amount
|
21
|
+
permit_attribute :product, Product.arel_table[:id]
|
22
|
+
permit_attribute :product_title, Product.arel_table[:title]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.term_search
|
27
|
+
@term_search ||= Pursuit::TermSearch.new(all) do
|
28
|
+
search_attribute :title
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.search(query)
|
33
|
+
predicate_search.apply(query)
|
34
|
+
rescue Parslet::ParseFailed
|
35
|
+
term_search.apply(query)
|
36
|
+
end
|
12
37
|
end
|