pursuit 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +17 -3
- data/lib/pursuit/constants.rb +1 -1
- data/lib/pursuit/dsl.rb +28 -0
- data/lib/pursuit/railtie.rb +2 -2
- data/lib/pursuit/search.rb +152 -0
- data/lib/pursuit/search_options.rb +72 -0
- data/lib/pursuit/{term_parser.rb → search_term_parser.rb} +4 -6
- data/lib/pursuit.rb +3 -2
- data/spec/internal/app/models/product.rb +17 -4
- data/spec/pursuit/dsl_spec.rb +22 -0
- data/spec/pursuit/search_options_spec.rb +74 -0
- data/spec/pursuit/{active_record_search_spec.rb → search_spec.rb} +57 -29
- data/spec/pursuit/search_term_parser_spec.rb +32 -0
- metadata +13 -10
- data/lib/pursuit/active_record_dsl.rb +0 -32
- data/lib/pursuit/active_record_search.rb +0 -165
- data/spec/pursuit/active_record_dsl_spec.rb +0 -7
- data/spec/pursuit/term_parser_spec.rb +0 -32
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2b49ae3ec2734b8b67fef2fb0686b85af3a694745db4654de863942d4a598d53
|
4
|
+
data.tar.gz: 8d8a6e71f23b31d0ebf33a1c2554a9875c0c189786954574c3d5f58ced5fe304
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: faebb7031e643b0fe4011cee2fda71a916dd12aa2819845a26bf79ef6ca0f0411bcd3e8b5ea1f092624909933639d97d0e59cc2b80ba38ea10f2dcbf0254c154
|
7
|
+
data.tar.gz: 0c06093eb83369d20f5e64a843483e98a019a5facb594f75b845949cfdcc0b7027da78ccb8be92d6a739cc76720e1d28165019aaaf7220718deb550c2816440a
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -20,9 +20,23 @@ You can use the convenient DSL syntax to declare which attributes and relationsh
|
|
20
20
|
|
21
21
|
```ruby
|
22
22
|
class Product < ActiveRecord::Base
|
23
|
-
|
24
|
-
|
25
|
-
|
23
|
+
searchable do |o|
|
24
|
+
o.relation :variations, :title, :stock_status
|
25
|
+
|
26
|
+
o.keyed :title
|
27
|
+
o.keyed :description
|
28
|
+
o.keyed :rating
|
29
|
+
|
30
|
+
# You can also create virtual attributes to search by passing in a block that returns an arel node.
|
31
|
+
o.keyed :title_length do
|
32
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
33
|
+
arel_table[:title]
|
34
|
+
])
|
35
|
+
end
|
36
|
+
|
37
|
+
o.unkeyed :title
|
38
|
+
o.unkeyed :description
|
39
|
+
end
|
26
40
|
end
|
27
41
|
```
|
28
42
|
|
data/lib/pursuit/constants.rb
CHANGED
data/lib/pursuit/dsl.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
module DSL
|
5
|
+
def self.included(base)
|
6
|
+
base.extend ClassMethods
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def searchable(&block)
|
11
|
+
if respond_to?(:search_options) || respond_to?(:search)
|
12
|
+
raise "#{self} already has #search and #search_options defined."
|
13
|
+
end
|
14
|
+
|
15
|
+
options = SearchOptions.new(self, &block)
|
16
|
+
|
17
|
+
define_singleton_method(:search_options) do
|
18
|
+
options
|
19
|
+
end
|
20
|
+
|
21
|
+
define_singleton_method(:search) do |query|
|
22
|
+
search = Pursuit::Search.new(options)
|
23
|
+
search.perform(query)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/pursuit/railtie.rb
CHANGED
@@ -4,9 +4,9 @@ module Pursuit
|
|
4
4
|
class Railtie < Rails::Railtie
|
5
5
|
initializer 'pursuit.active_record.inject_dsl' do
|
6
6
|
ActiveSupport.on_load(:active_record) do
|
7
|
-
require 'pursuit/
|
7
|
+
require 'pursuit/dsl'
|
8
8
|
|
9
|
-
ActiveRecord::Base.include(Pursuit::
|
9
|
+
ActiveRecord::Base.include(Pursuit::DSL)
|
10
10
|
end
|
11
11
|
end
|
12
12
|
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class Search
|
5
|
+
# @return [SearchOptions] The options to use when building the search query.
|
6
|
+
#
|
7
|
+
attr_reader :options
|
8
|
+
|
9
|
+
# Create a new instance to search a specific ActiveRecord record class.
|
10
|
+
#
|
11
|
+
# @param options [SearchOptions]
|
12
|
+
#
|
13
|
+
def initialize(options)
|
14
|
+
@options = options
|
15
|
+
end
|
16
|
+
|
17
|
+
# Perform a search for the specified query.
|
18
|
+
#
|
19
|
+
# @param query [String] The query to transform into a SQL search.
|
20
|
+
# @return [ActiveRecord::Relation] The search results.
|
21
|
+
#
|
22
|
+
def perform(query)
|
23
|
+
options.record_class.where(build_arel(query))
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def build_arel(query)
|
29
|
+
parser = SearchTermParser.new(query, keys: options.keys)
|
30
|
+
unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
|
31
|
+
keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)
|
32
|
+
|
33
|
+
if unkeyed_arel && keyed_arel
|
34
|
+
unkeyed_arel.and(keyed_arel)
|
35
|
+
else
|
36
|
+
unkeyed_arel || keyed_arel
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_arel_for_unkeyed_term(value)
|
41
|
+
return nil if value.blank?
|
42
|
+
|
43
|
+
sanitized_value = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
|
44
|
+
options.unkeyed_attributes.reduce(nil) do |chain, (attribute_name, node_builder)|
|
45
|
+
node = node_builder.call.matches(sanitized_value)
|
46
|
+
chain ? chain.or(node) : node
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def build_arel_for_keyed_terms(terms)
|
51
|
+
return nil if terms.blank?
|
52
|
+
|
53
|
+
terms.reduce(nil) do |chain, term|
|
54
|
+
reflection = options.record_class.reflections[term.key]
|
55
|
+
node = if reflection.present?
|
56
|
+
attribute_names = options.relations[term.key.to_sym]
|
57
|
+
build_arel_for_reflection(reflection, attribute_names, term.operator, term.value)
|
58
|
+
else
|
59
|
+
node_builder = options.keyed_attributes[term.key.to_sym]
|
60
|
+
build_arel_for_node(node_builder.call, term.operator, term.value)
|
61
|
+
end
|
62
|
+
|
63
|
+
chain ? chain.and(node) : node
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def build_arel_for_node(node, operator, value)
|
68
|
+
sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
|
69
|
+
sanitized_value = sanitized_value.to_i if sanitized_value =~ /^[0-9]+$/
|
70
|
+
|
71
|
+
case operator
|
72
|
+
when '>' then node.gt(sanitized_value)
|
73
|
+
when '>=' then node.gteq(sanitized_value)
|
74
|
+
when '<' then node.lt(sanitized_value)
|
75
|
+
when '<=' then node.lteq(sanitized_value)
|
76
|
+
when '*=' then node.matches("%#{sanitized_value}%")
|
77
|
+
when '!*=' then node.does_not_match("%#{sanitized_value}%")
|
78
|
+
when '!=' then node.not_eq(sanitized_value)
|
79
|
+
when '=='
|
80
|
+
if value.present?
|
81
|
+
node.eq(sanitized_value)
|
82
|
+
else
|
83
|
+
node.eq(nil).or(node.eq(''))
|
84
|
+
end
|
85
|
+
else
|
86
|
+
raise "The operator '#{operator}' is not supported."
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def build_arel_for_reflection(reflection, attribute_names, operator, value)
|
91
|
+
nodes = build_arel_for_reflection_join(reflection)
|
92
|
+
count_nodes = build_arel_for_relation_count(nodes, operator, value)
|
93
|
+
return count_nodes if count_nodes.present?
|
94
|
+
|
95
|
+
match_nodes = attribute_names.reduce(nil) do |chain, attribute_name|
|
96
|
+
node = build_arel_for_node(reflection.klass.arel_table[attribute_name], operator, value)
|
97
|
+
chain ? chain.or(node) : node
|
98
|
+
end
|
99
|
+
|
100
|
+
return nil if match_nodes.blank?
|
101
|
+
|
102
|
+
nodes.where(match_nodes).project(1).exists
|
103
|
+
end
|
104
|
+
|
105
|
+
def build_arel_for_reflection_join(reflection)
|
106
|
+
reflection_table = reflection.klass.arel_table
|
107
|
+
reflection_through = reflection.through_reflection
|
108
|
+
|
109
|
+
if reflection_through.present?
|
110
|
+
# :has_one through / :has_many through
|
111
|
+
reflection_through_table = reflection_through.klass.arel_table
|
112
|
+
reflection_table.join(reflection_through_table).on(
|
113
|
+
reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
|
114
|
+
).where(
|
115
|
+
reflection_through_table[reflection_through.foreign_key].eq(
|
116
|
+
options.record_class.arel_table[options.record_class.primary_key]
|
117
|
+
)
|
118
|
+
)
|
119
|
+
else
|
120
|
+
# :has_one / :has_many
|
121
|
+
reflection_table.where(
|
122
|
+
reflection_table[reflection.foreign_key].eq(
|
123
|
+
options.record_class.arel_table[options.record_class.primary_key]
|
124
|
+
)
|
125
|
+
)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def build_arel_for_relation_count(nodes, operator, value)
|
130
|
+
node_builder = proc do |klass|
|
131
|
+
count = ActiveRecord::Base.sanitize_sql_like(value).to_i
|
132
|
+
klass.new(nodes.project(Arel.star.count), count)
|
133
|
+
end
|
134
|
+
|
135
|
+
case operator
|
136
|
+
when '>' then node_builder.call(Arel::Nodes::GreaterThan)
|
137
|
+
when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
|
138
|
+
when '<' then node_builder.call(Arel::Nodes::LessThan)
|
139
|
+
when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
|
140
|
+
else
|
141
|
+
return nil unless value =~ /^([0-9]+)$/
|
142
|
+
|
143
|
+
case operator
|
144
|
+
when '==' then node_builder.call(Arel::Nodes::Equality)
|
145
|
+
when '!=' then node_builder.call(Arel::Nodes::NotEqual)
|
146
|
+
when '*=' then node_builder.call(Arel::Nodes::Matches)
|
147
|
+
when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class SearchOptions
|
5
|
+
# @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class to search.
|
6
|
+
#
|
7
|
+
attr_reader :record_class
|
8
|
+
|
9
|
+
# @return [Hash<Symbol, Array<Symbol>>] The record's relatives and the attribute names that can be searched.
|
10
|
+
#
|
11
|
+
attr_reader :relations
|
12
|
+
|
13
|
+
# @return [Hash<Symbol, Proc>] The attribute names which can be searched with a keyed term (e.g. 'last_name:*herb').
|
14
|
+
#
|
15
|
+
attr_reader :keyed_attributes
|
16
|
+
|
17
|
+
# @return [Hash<Symbol, Proc>] The attribute names which can be searched with an unkeyed term (e.g. 'herb').
|
18
|
+
#
|
19
|
+
attr_reader :unkeyed_attributes
|
20
|
+
|
21
|
+
# Create a new `SearchOptions` ready for adding options.
|
22
|
+
#
|
23
|
+
# @params record_class [Class<ActiveRecord::Base>]
|
24
|
+
# @params block [Proc]
|
25
|
+
#
|
26
|
+
def initialize(record_class, &block)
|
27
|
+
@record_class = record_class
|
28
|
+
@relations = {}
|
29
|
+
@keyed_attributes = {}
|
30
|
+
@unkeyed_attributes = {}
|
31
|
+
|
32
|
+
block.call(self) if block
|
33
|
+
end
|
34
|
+
|
35
|
+
# @return [Array<String>] The collection of all possible attributes which can be used as a keyed term.
|
36
|
+
#
|
37
|
+
def keys
|
38
|
+
keys = relations.keys + keyed_attributes.keys
|
39
|
+
keys.map(&:to_s).uniq
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add a relation to the search options.
|
43
|
+
#
|
44
|
+
# @param name [Symbol] The name of the relationship attribute.
|
45
|
+
# @param attribute_names [Splat] The name of the attributes within the relationship to search.
|
46
|
+
#
|
47
|
+
def relation(name, *attribute_names)
|
48
|
+
relations[name] = attribute_names
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
|
52
|
+
# Add a keyed attribute to search.
|
53
|
+
#
|
54
|
+
# @param name [Symbol] The name of the attribute.
|
55
|
+
# @param block [Proc] A block which returns an arel node to query against instead of a real attribute.
|
56
|
+
#
|
57
|
+
def keyed(name, &block)
|
58
|
+
keyed_attributes[name] = block || -> { record_class.arel_table[name] }
|
59
|
+
nil
|
60
|
+
end
|
61
|
+
|
62
|
+
# Add an unkeyed attribute to search.
|
63
|
+
#
|
64
|
+
# @param name [Symbol] The name of the attribute.
|
65
|
+
# @param block [Proc] A block which returns an arel node to query against instead of a real attribute.
|
66
|
+
#
|
67
|
+
def unkeyed(name, &block)
|
68
|
+
unkeyed_attributes[name] = block || -> { record_class.arel_table[name] }
|
69
|
+
nil
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -1,12 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Pursuit
|
4
|
-
class
|
4
|
+
class SearchTermParser
|
5
5
|
# @return [Struct] Represents a single keyed term extracted from a query.
|
6
6
|
#
|
7
7
|
KeyedTerm = Struct.new(:key, :operator, :value)
|
8
8
|
|
9
|
-
# @return [Array<Pursuit::
|
9
|
+
# @return [Array<Pursuit::SearchTermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
|
10
10
|
#
|
11
11
|
attr_reader :keyed_terms
|
12
12
|
|
@@ -14,14 +14,12 @@ module Pursuit
|
|
14
14
|
#
|
15
15
|
attr_reader :unkeyed_term
|
16
16
|
|
17
|
-
# Create a new
|
17
|
+
# Create a new search term parser by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
|
18
18
|
#
|
19
19
|
# @param query [String] The query to parse.
|
20
|
-
# @param keys [Array<
|
20
|
+
# @param keys [Array<String>] The keys which are permitted for use as keyed terms.
|
21
21
|
#
|
22
22
|
def initialize(query, keys: [])
|
23
|
-
keys = keys.map(&:to_s)
|
24
|
-
|
25
23
|
@keyed_terms = []
|
26
24
|
@unkeyed_term = query.gsub(/(\s+)?(\w+)(==|\*=|!=|!\*=|<=|>=|<|>)("([^"]+)?"|'([^']+)?'|[^\s]+)(\s+)?/) do |term|
|
27
25
|
key = Regexp.last_match(2)
|
data/lib/pursuit.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'pursuit/constants'
|
4
|
-
require 'pursuit/
|
5
|
-
require 'pursuit/
|
4
|
+
require 'pursuit/search_options'
|
5
|
+
require 'pursuit/search_term_parser'
|
6
|
+
require 'pursuit/search'
|
6
7
|
require 'pursuit/railtie' if defined?(Rails::Railtie)
|
@@ -3,9 +3,22 @@
|
|
3
3
|
class Product < ActiveRecord::Base
|
4
4
|
has_many :variations, class_name: 'ProductVariation', inverse_of: :product
|
5
5
|
|
6
|
-
has_search relationships: { variations: %i[title stock_status] },
|
7
|
-
keyed_attributes: %i[title description rating],
|
8
|
-
unkeyed_attributes: %i[title description]
|
9
|
-
|
10
6
|
validates :title, presence: true
|
7
|
+
|
8
|
+
searchable do |o|
|
9
|
+
o.relation :variations, :title, :stock_status
|
10
|
+
|
11
|
+
o.keyed :title
|
12
|
+
o.keyed :description
|
13
|
+
o.keyed :rating
|
14
|
+
|
15
|
+
o.keyed :title_length do
|
16
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
17
|
+
arel_table[:title]
|
18
|
+
])
|
19
|
+
end
|
20
|
+
|
21
|
+
o.unkeyed :title
|
22
|
+
o.unkeyed :description
|
23
|
+
end
|
11
24
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe Pursuit::DSL do
|
4
|
+
subject(:record) { Product.new }
|
5
|
+
|
6
|
+
describe '.search_options' do
|
7
|
+
subject(:search_options) { record.class.search_options }
|
8
|
+
|
9
|
+
it { is_expected.to be_a(Pursuit::SearchOptions) }
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '.search' do
|
13
|
+
subject(:search) { record.class.search('funky') }
|
14
|
+
|
15
|
+
let(:product_a) { Product.create!(title: 'Plain Shirt') }
|
16
|
+
let(:product_b) { Product.create!(title: 'Funky Shirt') }
|
17
|
+
|
18
|
+
it 'is expected to return the matching records' do
|
19
|
+
expect(search).to contain_exactly(product_b)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe Pursuit::SearchOptions do
|
4
|
+
subject(:search_options) { described_class.new(Product) }
|
5
|
+
|
6
|
+
let(:title_length_node_builder) do
|
7
|
+
proc do
|
8
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
9
|
+
Product.arel_table[:title]
|
10
|
+
])
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
before do
|
15
|
+
search_options.relation :variations, :title, :stock_status
|
16
|
+
|
17
|
+
search_options.keyed :title
|
18
|
+
search_options.keyed :title_length, &title_length_node_builder
|
19
|
+
search_options.keyed :description
|
20
|
+
search_options.keyed :rating
|
21
|
+
|
22
|
+
search_options.unkeyed :title
|
23
|
+
search_options.unkeyed :title_length, &title_length_node_builder
|
24
|
+
search_options.unkeyed :description
|
25
|
+
end
|
26
|
+
|
27
|
+
describe '#record_class' do
|
28
|
+
subject(:record_class) { search_options.record_class }
|
29
|
+
|
30
|
+
it 'is expected to eq the class passed during initialization' do
|
31
|
+
expect(record_class).to eq(Product)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#relations' do
|
36
|
+
subject(:relations) { search_options.relations }
|
37
|
+
|
38
|
+
it 'is expected to contain the correct relations' do
|
39
|
+
expect(relations).to eq(variations: %i[title stock_status])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe '#keyed_attributes' do
|
44
|
+
subject(:keyed_attributes) { search_options.keyed_attributes }
|
45
|
+
|
46
|
+
it 'is expected to contain the correct keyed attributes' do
|
47
|
+
expect(keyed_attributes.keys).to contain_exactly(:title, :title_length, :description, :rating)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'is expected to set a default node builder for attributes declared without a block' do
|
51
|
+
expect(keyed_attributes[:title].call).to eq(Product.arel_table[:title])
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'is expected to set a custom node builder for attributes declared with a block' do
|
55
|
+
expect(keyed_attributes[:title_length]).to eq(title_length_node_builder)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
describe '#unkeyed_attributes' do
|
60
|
+
subject(:unkeyed_attributes) { search_options.unkeyed_attributes }
|
61
|
+
|
62
|
+
it 'is expected to contain the correct unkeyed attributes' do
|
63
|
+
expect(unkeyed_attributes.keys).to contain_exactly(:title, :title_length, :description)
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'is expected to set a default node builder for attributes declared without a block' do
|
67
|
+
expect(unkeyed_attributes[:title].call).to eq(Product.arel_table[:title])
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'is expected to set a custom node builder for attributes declared with a block' do
|
71
|
+
expect(unkeyed_attributes[:title_length]).to eq(title_length_node_builder)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -1,17 +1,29 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
RSpec.describe Pursuit::
|
4
|
-
subject(:
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
3
|
+
RSpec.describe Pursuit::Search do
|
4
|
+
subject(:search) { described_class.new(search_options) }
|
5
|
+
|
6
|
+
let(:search_options) do
|
7
|
+
Pursuit::SearchOptions.new(Product) do |o|
|
8
|
+
o.relation :variations, :title, :stock_status
|
9
|
+
|
10
|
+
o.keyed :title
|
11
|
+
o.keyed :description
|
12
|
+
o.keyed :rating
|
13
|
+
|
14
|
+
o.keyed :title_length do
|
15
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
16
|
+
Product.arel_table[:title]
|
17
|
+
])
|
18
|
+
end
|
19
|
+
|
20
|
+
o.unkeyed :title
|
21
|
+
o.unkeyed :description
|
22
|
+
end
|
11
23
|
end
|
12
24
|
|
13
|
-
describe '#
|
14
|
-
subject(:
|
25
|
+
describe '#perform' do
|
26
|
+
subject(:perform) { search.perform(query) }
|
15
27
|
|
16
28
|
context 'when passed a blank query' do
|
17
29
|
let(:query) { '' }
|
@@ -25,7 +37,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
25
37
|
end
|
26
38
|
|
27
39
|
it 'is expected to contain all records' do
|
28
|
-
expect(
|
40
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
29
41
|
end
|
30
42
|
end
|
31
43
|
|
@@ -43,7 +55,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
43
55
|
end
|
44
56
|
|
45
57
|
it 'is expected to contain the matching records' do
|
46
|
-
expect(
|
58
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
47
59
|
end
|
48
60
|
end
|
49
61
|
|
@@ -61,7 +73,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
61
73
|
end
|
62
74
|
|
63
75
|
it 'is expected to contain the matching records' do
|
64
|
-
expect(
|
76
|
+
expect(perform).to contain_exactly(product_b)
|
65
77
|
end
|
66
78
|
end
|
67
79
|
|
@@ -79,7 +91,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
79
91
|
end
|
80
92
|
|
81
93
|
it 'is expected to contain the matching records' do
|
82
|
-
expect(
|
94
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
83
95
|
end
|
84
96
|
end
|
85
97
|
|
@@ -97,7 +109,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
97
109
|
end
|
98
110
|
|
99
111
|
it 'is expected to contain the matching records' do
|
100
|
-
expect(
|
112
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
101
113
|
end
|
102
114
|
end
|
103
115
|
|
@@ -115,7 +127,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
115
127
|
end
|
116
128
|
|
117
129
|
it 'is expected to contain the matching records' do
|
118
|
-
expect(
|
130
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
119
131
|
end
|
120
132
|
end
|
121
133
|
|
@@ -133,7 +145,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
133
145
|
end
|
134
146
|
|
135
147
|
it 'is expected to contain the matching records' do
|
136
|
-
expect(
|
148
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
137
149
|
end
|
138
150
|
end
|
139
151
|
|
@@ -151,7 +163,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
151
163
|
end
|
152
164
|
|
153
165
|
it 'is expected to contain the matching records' do
|
154
|
-
expect(
|
166
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
155
167
|
end
|
156
168
|
end
|
157
169
|
|
@@ -169,7 +181,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
169
181
|
end
|
170
182
|
|
171
183
|
it 'is expected to contain the matching records' do
|
172
|
-
expect(
|
184
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
173
185
|
end
|
174
186
|
end
|
175
187
|
|
@@ -187,7 +199,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
187
199
|
end
|
188
200
|
|
189
201
|
it 'is expected to contain the matching records' do
|
190
|
-
expect(
|
202
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
191
203
|
end
|
192
204
|
end
|
193
205
|
|
@@ -205,7 +217,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
205
217
|
end
|
206
218
|
|
207
219
|
it 'is expected to contain the matching records' do
|
208
|
-
expect(
|
220
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
209
221
|
end
|
210
222
|
end
|
211
223
|
|
@@ -223,7 +235,23 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
223
235
|
end
|
224
236
|
|
225
237
|
it 'is expected to contain the matching records' do
|
226
|
-
expect(
|
238
|
+
expect(perform).to contain_exactly(product_b)
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
context 'when passed a virtual keyed attribute query' do
|
243
|
+
let(:query) { 'title_length==5' }
|
244
|
+
|
245
|
+
let(:product_a) { Product.create!(title: 'Plain Shirt') }
|
246
|
+
let(:product_b) { Product.create!(title: 'Socks') }
|
247
|
+
|
248
|
+
before do
|
249
|
+
product_a
|
250
|
+
product_b
|
251
|
+
end
|
252
|
+
|
253
|
+
it 'is expected to contain the matching records' do
|
254
|
+
expect(perform).to contain_exactly(product_b)
|
227
255
|
end
|
228
256
|
end
|
229
257
|
|
@@ -247,7 +275,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
247
275
|
end
|
248
276
|
|
249
277
|
it 'is expected to contain the matching records' do
|
250
|
-
expect(
|
278
|
+
expect(perform).to contain_exactly(product_b)
|
251
279
|
end
|
252
280
|
end
|
253
281
|
|
@@ -275,7 +303,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
275
303
|
end
|
276
304
|
|
277
305
|
it 'is expected to contain the matching records' do
|
278
|
-
expect(
|
306
|
+
expect(perform).to contain_exactly(product_c)
|
279
307
|
end
|
280
308
|
end
|
281
309
|
|
@@ -307,7 +335,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
307
335
|
end
|
308
336
|
|
309
337
|
it 'is expected to contain the matching records' do
|
310
|
-
expect(
|
338
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
311
339
|
end
|
312
340
|
end
|
313
341
|
|
@@ -339,7 +367,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
339
367
|
end
|
340
368
|
|
341
369
|
it 'is expected to contain the matching records' do
|
342
|
-
expect(
|
370
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
343
371
|
end
|
344
372
|
end
|
345
373
|
|
@@ -371,7 +399,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
371
399
|
end
|
372
400
|
|
373
401
|
it 'is expected to contain the matching records' do
|
374
|
-
expect(
|
402
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
375
403
|
end
|
376
404
|
end
|
377
405
|
|
@@ -403,7 +431,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
403
431
|
end
|
404
432
|
|
405
433
|
it 'is expected to contain the matching records' do
|
406
|
-
expect(
|
434
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
407
435
|
end
|
408
436
|
end
|
409
437
|
|
@@ -435,7 +463,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
435
463
|
end
|
436
464
|
|
437
465
|
it 'is expected to contain the matching records' do
|
438
|
-
expect(
|
466
|
+
expect(perform).to contain_exactly(product_a)
|
439
467
|
end
|
440
468
|
end
|
441
469
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe Pursuit::SearchTermParser do
|
4
|
+
subject(:parser) { described_class.new(query, keys: keys) }
|
5
|
+
|
6
|
+
let(:keys) { %w[title description rating stock_status] }
|
7
|
+
let(:query) do
|
8
|
+
"plain title!='Socks' description*=\"green\" stock_status==in_stock shirt rating>=2 other*=thing rating<5"
|
9
|
+
end
|
10
|
+
|
11
|
+
describe '#unkeyed_term' do
|
12
|
+
subject(:unkeyed_term) { parser.unkeyed_term }
|
13
|
+
|
14
|
+
it 'is expected to eq the correct unkeyed term' do
|
15
|
+
expect(unkeyed_term).to eq('plain shirt other*=thing')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#keyed_terms' do
|
20
|
+
subject(:keyed_terms) { parser.keyed_terms }
|
21
|
+
|
22
|
+
it 'is expected to eq the correct keyed terms' do
|
23
|
+
expect(keyed_terms).to eq([
|
24
|
+
Pursuit::SearchTermParser::KeyedTerm.new('title', '!=', 'Socks'),
|
25
|
+
Pursuit::SearchTermParser::KeyedTerm.new('description', '*=', 'green'),
|
26
|
+
Pursuit::SearchTermParser::KeyedTerm.new('stock_status', '==', 'in_stock'),
|
27
|
+
Pursuit::SearchTermParser::KeyedTerm.new('rating', '>=', '2'),
|
28
|
+
Pursuit::SearchTermParser::KeyedTerm.new('rating', '<', '5')
|
29
|
+
])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pursuit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nialto Services
|
@@ -199,21 +199,23 @@ files:
|
|
199
199
|
- bin/setup
|
200
200
|
- config.ru
|
201
201
|
- lib/pursuit.rb
|
202
|
-
- lib/pursuit/active_record_dsl.rb
|
203
|
-
- lib/pursuit/active_record_search.rb
|
204
202
|
- lib/pursuit/constants.rb
|
203
|
+
- lib/pursuit/dsl.rb
|
205
204
|
- lib/pursuit/railtie.rb
|
206
|
-
- lib/pursuit/
|
205
|
+
- lib/pursuit/search.rb
|
206
|
+
- lib/pursuit/search_options.rb
|
207
|
+
- lib/pursuit/search_term_parser.rb
|
207
208
|
- pursuit.gemspec
|
208
209
|
- spec/internal/app/models/product.rb
|
209
210
|
- spec/internal/app/models/product_variation.rb
|
210
211
|
- spec/internal/config/database.yml
|
211
212
|
- spec/internal/db/schema.rb
|
212
213
|
- spec/internal/log/.keep
|
213
|
-
- spec/pursuit/active_record_dsl_spec.rb
|
214
|
-
- spec/pursuit/active_record_search_spec.rb
|
215
214
|
- spec/pursuit/constants_spec.rb
|
216
|
-
- spec/pursuit/
|
215
|
+
- spec/pursuit/dsl_spec.rb
|
216
|
+
- spec/pursuit/search_options_spec.rb
|
217
|
+
- spec/pursuit/search_spec.rb
|
218
|
+
- spec/pursuit/search_term_parser_spec.rb
|
217
219
|
- spec/spec_helper.rb
|
218
220
|
- travis/gemfiles/5.2.gemfile
|
219
221
|
- travis/gemfiles/6.0.gemfile
|
@@ -248,8 +250,9 @@ test_files:
|
|
248
250
|
- spec/internal/config/database.yml
|
249
251
|
- spec/internal/db/schema.rb
|
250
252
|
- spec/internal/log/.keep
|
251
|
-
- spec/pursuit/active_record_dsl_spec.rb
|
252
|
-
- spec/pursuit/active_record_search_spec.rb
|
253
253
|
- spec/pursuit/constants_spec.rb
|
254
|
-
- spec/pursuit/
|
254
|
+
- spec/pursuit/dsl_spec.rb
|
255
|
+
- spec/pursuit/search_options_spec.rb
|
256
|
+
- spec/pursuit/search_spec.rb
|
257
|
+
- spec/pursuit/search_term_parser_spec.rb
|
255
258
|
- spec/spec_helper.rb
|
@@ -1,32 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Pursuit
|
4
|
-
# Provides a DSL for the `ActiveRecord::Base` class.
|
5
|
-
#
|
6
|
-
module ActiveRecordDSL
|
7
|
-
def self.included(base)
|
8
|
-
base.extend ClassMethods
|
9
|
-
end
|
10
|
-
|
11
|
-
module ClassMethods
|
12
|
-
def has_search(relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
|
13
|
-
raise 'The #search method has already been defined.' if respond_to?(:search)
|
14
|
-
|
15
|
-
# The value of `self` is a constant for the current `ActiveRecord::Base` subclass. We'll need to capture this
|
16
|
-
# in a custom variable to make it accessible from within the #define_method block.
|
17
|
-
klass = self
|
18
|
-
|
19
|
-
define_method(:search) do |query|
|
20
|
-
search = Pursuit::ActiveRecordSearch.new(
|
21
|
-
klass,
|
22
|
-
relationships: relationships,
|
23
|
-
keyed_attributes: keyed_attributes,
|
24
|
-
unkeyed_attributes: unkeyed_attributes
|
25
|
-
)
|
26
|
-
|
27
|
-
search.search(query)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|
@@ -1,165 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Pursuit
|
4
|
-
class ActiveRecordSearch
|
5
|
-
# @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class being searched.
|
6
|
-
#
|
7
|
-
attr_reader :klass
|
8
|
-
|
9
|
-
# @return [Hash<Symbol, Array<Symbol>>] The attribute names for relationships which can be queried with a keyed
|
10
|
-
# term and the attributes in the relative's table which should be searched.
|
11
|
-
#
|
12
|
-
attr_accessor :relationships
|
13
|
-
|
14
|
-
# @return [Array<Symbol>] The attribute names which can be queried with a keyed term (e.g. 'last_name:*herb').
|
15
|
-
#
|
16
|
-
attr_accessor :keyed_attributes
|
17
|
-
|
18
|
-
# @return [Array<Symbol>] The attribute names which can be queried with an unkeyed term (e.g. 'herb').
|
19
|
-
#
|
20
|
-
attr_accessor :unkeyed_attributes
|
21
|
-
|
22
|
-
# Create a new instance to search a specific ActiveRecord record class.
|
23
|
-
#
|
24
|
-
# @param klass [Class<ActiveRecord::Base>]
|
25
|
-
# @param relationships [Hash<Symbol, Array<Symbol>>]
|
26
|
-
# @param keyed_attributes [Array<Symbol>]
|
27
|
-
# @param unkeyed_attributes [Array<Symbol>]
|
28
|
-
#
|
29
|
-
def initialize(klass, relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
|
30
|
-
@klass = klass
|
31
|
-
@relationships = relationships
|
32
|
-
@keyed_attributes = keyed_attributes
|
33
|
-
@unkeyed_attributes = unkeyed_attributes
|
34
|
-
end
|
35
|
-
|
36
|
-
# Search the record with the specified query.
|
37
|
-
#
|
38
|
-
# @param query [String] The query to transform into a SQL search.
|
39
|
-
# @return [ActiveRecord::Relation] The search results.
|
40
|
-
#
|
41
|
-
def search(query)
|
42
|
-
klass.where(build_arel(query))
|
43
|
-
end
|
44
|
-
|
45
|
-
private
|
46
|
-
|
47
|
-
def build_arel(query)
|
48
|
-
parser = TermParser.new(query, keys: relationships.keys + keyed_attributes)
|
49
|
-
unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
|
50
|
-
keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)
|
51
|
-
|
52
|
-
if unkeyed_arel && keyed_arel
|
53
|
-
unkeyed_arel.and(keyed_arel)
|
54
|
-
else
|
55
|
-
unkeyed_arel || keyed_arel
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
def build_arel_for_unkeyed_term(value)
|
60
|
-
return nil if value.blank?
|
61
|
-
|
62
|
-
sanitized_value = "%#{klass.sanitize_sql_like(value)}%"
|
63
|
-
unkeyed_attributes.reduce(nil) do |chain, attribute_name|
|
64
|
-
node = klass.arel_table[attribute_name].matches(sanitized_value)
|
65
|
-
chain ? chain.or(node) : node
|
66
|
-
end
|
67
|
-
end
|
68
|
-
|
69
|
-
def build_arel_for_keyed_terms(terms)
|
70
|
-
return nil if terms.blank?
|
71
|
-
|
72
|
-
terms.reduce(nil) do |chain, term|
|
73
|
-
reflection = klass.reflections[term.key]
|
74
|
-
node = if reflection.present?
|
75
|
-
keys = relationships[term.key.to_sym].presence || []
|
76
|
-
build_arel_for_reflection(reflection, keys, term.operator, term.value)
|
77
|
-
else
|
78
|
-
build_arel_for_attribute(klass.arel_table[term.key], term.operator, term.value)
|
79
|
-
end
|
80
|
-
|
81
|
-
chain ? chain.and(node) : node
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
def build_arel_for_attribute(attribute, operator, value)
|
86
|
-
sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
|
87
|
-
|
88
|
-
case operator
|
89
|
-
when '>' then attribute.gt(sanitized_value)
|
90
|
-
when '>=' then attribute.gteq(sanitized_value)
|
91
|
-
when '<' then attribute.lt(sanitized_value)
|
92
|
-
when '<=' then attribute.lteq(sanitized_value)
|
93
|
-
when '*=' then attribute.matches("%#{sanitized_value}%")
|
94
|
-
when '!*=' then attribute.does_not_match("%#{sanitized_value}%")
|
95
|
-
when '!=' then attribute.not_eq(sanitized_value)
|
96
|
-
when '=='
|
97
|
-
if value.present?
|
98
|
-
attribute.eq(sanitized_value)
|
99
|
-
else
|
100
|
-
attribute.eq(nil).or(attribute.eq(''))
|
101
|
-
end
|
102
|
-
else
|
103
|
-
raise "The operator '#{operator}' is not supported."
|
104
|
-
end
|
105
|
-
end
|
106
|
-
|
107
|
-
def build_arel_for_reflection(reflection, relation_attributes, operator, value)
|
108
|
-
nodes = build_arel_for_reflection_join(reflection)
|
109
|
-
count_nodes = build_arel_for_relation_count(nodes, operator, value)
|
110
|
-
return count_nodes if count_nodes.present?
|
111
|
-
|
112
|
-
match_nodes = relation_attributes.reduce(nil) do |chain, attribute_name|
|
113
|
-
node = build_arel_for_attribute(reflection.klass.arel_table[attribute_name], operator, value)
|
114
|
-
chain ? chain.or(node) : node
|
115
|
-
end
|
116
|
-
|
117
|
-
return nil if match_nodes.blank?
|
118
|
-
|
119
|
-
nodes.where(match_nodes).project(1).exists
|
120
|
-
end
|
121
|
-
|
122
|
-
def build_arel_for_reflection_join(reflection)
|
123
|
-
reflection_table = reflection.klass.arel_table
|
124
|
-
reflection_through = reflection.through_reflection
|
125
|
-
|
126
|
-
if reflection_through.present?
|
127
|
-
# :has_one through / :has_many through
|
128
|
-
reflection_through_table = reflection_through.klass.arel_table
|
129
|
-
reflection_table.join(reflection_through_table).on(
|
130
|
-
reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
|
131
|
-
).where(
|
132
|
-
reflection_through_table[reflection_through.foreign_key].eq(klass.arel_table[klass.primary_key])
|
133
|
-
)
|
134
|
-
else
|
135
|
-
# :has_one / :has_many
|
136
|
-
reflection_table.where(
|
137
|
-
reflection_table[reflection.foreign_key].eq(klass.arel_table[klass.primary_key])
|
138
|
-
)
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
def build_arel_for_relation_count(nodes, operator, value)
|
143
|
-
build = proc do |klass|
|
144
|
-
count = ActiveRecord::Base.sanitize_sql_like(value).to_i
|
145
|
-
klass.new(nodes.project(Arel.star.count), count)
|
146
|
-
end
|
147
|
-
|
148
|
-
case operator
|
149
|
-
when '>' then build.call(Arel::Nodes::GreaterThan)
|
150
|
-
when '>=' then build.call(Arel::Nodes::GreaterThanOrEqual)
|
151
|
-
when '<' then build.call(Arel::Nodes::LessThan)
|
152
|
-
when '<=' then build.call(Arel::Nodes::LessThanOrEqual)
|
153
|
-
else
|
154
|
-
return nil unless value =~ /^([0-9]+)$/
|
155
|
-
|
156
|
-
case operator
|
157
|
-
when '==' then build.call(Arel::Nodes::Equality)
|
158
|
-
when '!=' then build.call(Arel::Nodes::NotEqual)
|
159
|
-
when '*=' then build.call(Arel::Nodes::Matches)
|
160
|
-
when '!*=' then build.call(Arel::Nodes::DoesNotMatch)
|
161
|
-
end
|
162
|
-
end
|
163
|
-
end
|
164
|
-
end
|
165
|
-
end
|
@@ -1,32 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
RSpec.describe Pursuit::TermParser do
|
4
|
-
subject(:term_parser) { described_class.new(query, keys: keys) }
|
5
|
-
|
6
|
-
let(:keys) { %i[title description rating stock_status] }
|
7
|
-
let(:query) do
|
8
|
-
"plain title!='Socks' description*=\"green\" stock_status==in_stock shirt rating>=2 other*=thing rating<5"
|
9
|
-
end
|
10
|
-
|
11
|
-
describe '#unkeyed_term' do
|
12
|
-
subject(:unkeyed_term) { term_parser.unkeyed_term }
|
13
|
-
|
14
|
-
it 'is expected to eq the correct unkeyed term' do
|
15
|
-
expect(unkeyed_term).to eq('plain shirt other*=thing')
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
describe '#keyed_terms' do
|
20
|
-
subject(:keyed_terms) { term_parser.keyed_terms }
|
21
|
-
|
22
|
-
it 'is expected to eq the correct keyed terms' do
|
23
|
-
expect(keyed_terms).to eq([
|
24
|
-
Pursuit::TermParser::KeyedTerm.new('title', '!=', 'Socks'),
|
25
|
-
Pursuit::TermParser::KeyedTerm.new('description', '*=', 'green'),
|
26
|
-
Pursuit::TermParser::KeyedTerm.new('stock_status', '==', 'in_stock'),
|
27
|
-
Pursuit::TermParser::KeyedTerm.new('rating', '>=', '2'),
|
28
|
-
Pursuit::TermParser::KeyedTerm.new('rating', '<', '5')
|
29
|
-
])
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|