pursuit 0.1.1 → 0.3.2
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 +22 -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 +153 -0
- data/lib/pursuit/search_options.rb +86 -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/internal/app/models/product_category.rb +7 -0
- data/spec/internal/db/schema.rb +8 -0
- data/spec/pursuit/dsl_spec.rb +22 -0
- data/spec/pursuit/search_options_spec.rb +148 -0
- data/spec/pursuit/{active_record_search_spec.rb → search_spec.rb} +76 -29
- data/spec/pursuit/search_term_parser_spec.rb +32 -0
- metadata +16 -11
- 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: 5cf47bc77d2ccf93d20d66ee529c6554a995ee612e3135f90fd7d437f0fa5b08
|
4
|
+
data.tar.gz: 8ac3ace51a32566144c34cbe9ebb33d0c8327327f8ce1d2d8ae120c079a494f8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 184fd8c2520cedd0c3ac45e83e4040295de141065419ee676ea159d8a419222a9bac51a950d6e0decb0e7106f8a3839d76f1a3144786647db528b9115d10bb9b
|
7
|
+
data.tar.gz: 65462af7fb13f713f4680845ba297bf659d230d9285b89b2b4c1de1bde2a64402a022c7929298d022f2a9b7a8fa1f4e0bbb7e569fb88cba99e39c01db17f244d
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -20,9 +20,28 @@ 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
|
+
# 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
|
26
45
|
end
|
27
46
|
```
|
28
47
|
|
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,153 @@
|
|
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
|
+
attribute_name = term.key.to_sym
|
55
|
+
reflection = options.relations.key?(attribute_name) ? options.record_class.reflections[term.key] : nil
|
56
|
+
node = if reflection.present?
|
57
|
+
attribute_names = options.relations[attribute_name]
|
58
|
+
build_arel_for_reflection(reflection, attribute_names, term.operator, term.value)
|
59
|
+
else
|
60
|
+
node_builder = options.keyed_attributes[attribute_name]
|
61
|
+
build_arel_for_node(node_builder.call, term.operator, term.value)
|
62
|
+
end
|
63
|
+
|
64
|
+
chain ? chain.and(node) : node
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def build_arel_for_node(node, operator, value)
|
69
|
+
sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
|
70
|
+
sanitized_value = sanitized_value.to_i if sanitized_value =~ /^[0-9]+$/
|
71
|
+
|
72
|
+
case operator
|
73
|
+
when '>' then node.gt(sanitized_value)
|
74
|
+
when '>=' then node.gteq(sanitized_value)
|
75
|
+
when '<' then node.lt(sanitized_value)
|
76
|
+
when '<=' then node.lteq(sanitized_value)
|
77
|
+
when '*=' then node.matches("%#{sanitized_value}%")
|
78
|
+
when '!*=' then node.does_not_match("%#{sanitized_value}%")
|
79
|
+
when '!=' then node.not_eq(sanitized_value)
|
80
|
+
when '=='
|
81
|
+
if value.present?
|
82
|
+
node.eq(sanitized_value)
|
83
|
+
else
|
84
|
+
node.eq(nil).or(node.eq(''))
|
85
|
+
end
|
86
|
+
else
|
87
|
+
raise "The operator '#{operator}' is not supported."
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def build_arel_for_reflection(reflection, attribute_names, operator, value)
|
92
|
+
nodes = build_arel_for_reflection_join(reflection)
|
93
|
+
count_nodes = build_arel_for_relation_count(nodes, operator, value)
|
94
|
+
return count_nodes if count_nodes.present?
|
95
|
+
|
96
|
+
match_nodes = attribute_names.reduce(nil) do |chain, attribute_name|
|
97
|
+
node = build_arel_for_node(reflection.klass.arel_table[attribute_name], operator, value)
|
98
|
+
chain ? chain.or(node) : node
|
99
|
+
end
|
100
|
+
|
101
|
+
return nil if match_nodes.blank?
|
102
|
+
|
103
|
+
nodes.where(match_nodes).project(1).exists
|
104
|
+
end
|
105
|
+
|
106
|
+
def build_arel_for_reflection_join(reflection)
|
107
|
+
reflection_table = reflection.klass.arel_table
|
108
|
+
reflection_through = reflection.through_reflection
|
109
|
+
|
110
|
+
if reflection_through.present?
|
111
|
+
# :has_one through / :has_many through
|
112
|
+
reflection_through_table = reflection_through.klass.arel_table
|
113
|
+
reflection_table.join(reflection_through_table).on(
|
114
|
+
reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
|
115
|
+
).where(
|
116
|
+
reflection_through_table[reflection_through.foreign_key].eq(
|
117
|
+
options.record_class.arel_table[options.record_class.primary_key]
|
118
|
+
)
|
119
|
+
)
|
120
|
+
else
|
121
|
+
# :has_one / :has_many
|
122
|
+
reflection_table.where(
|
123
|
+
reflection_table[reflection.foreign_key].eq(
|
124
|
+
options.record_class.arel_table[options.record_class.primary_key]
|
125
|
+
)
|
126
|
+
)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def build_arel_for_relation_count(nodes, operator, value)
|
131
|
+
node_builder = proc do |klass|
|
132
|
+
count = ActiveRecord::Base.sanitize_sql_like(value).to_i
|
133
|
+
klass.new(nodes.project(Arel.star.count), count)
|
134
|
+
end
|
135
|
+
|
136
|
+
case operator
|
137
|
+
when '>' then node_builder.call(Arel::Nodes::GreaterThan)
|
138
|
+
when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
|
139
|
+
when '<' then node_builder.call(Arel::Nodes::LessThan)
|
140
|
+
when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
|
141
|
+
else
|
142
|
+
return nil unless value =~ /^([0-9]+)$/
|
143
|
+
|
144
|
+
case operator
|
145
|
+
when '==' then node_builder.call(Arel::Nodes::Equality)
|
146
|
+
when '!=' then node_builder.call(Arel::Nodes::NotEqual)
|
147
|
+
when '*=' then node_builder.call(Arel::Nodes::Matches)
|
148
|
+
when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pursuit
|
4
|
+
class SearchOptions
|
5
|
+
# @return [Struct] The structure which holds the search options for a single attribute.
|
6
|
+
#
|
7
|
+
AttributeOptions = Struct.new(:keyed, :unkeyed, :block)
|
8
|
+
|
9
|
+
# @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class to search.
|
10
|
+
#
|
11
|
+
attr_reader :record_class
|
12
|
+
|
13
|
+
# @return [Hash<Symbol, Array<Symbol>>] The attribute names of the record's relatives which can be searched.
|
14
|
+
#
|
15
|
+
attr_reader :relations
|
16
|
+
|
17
|
+
# @return [Hash<Symbol, AttributeOptions>] The attributes which can be searched.
|
18
|
+
#
|
19
|
+
attr_reader :attributes
|
20
|
+
|
21
|
+
# Create a new `SearchOptions` and call the passed block to setup the 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
|
+
@attributes = {}
|
30
|
+
|
31
|
+
block.call(self) if block
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Hash<Symbol, Proc>] The attributes which can be queried using a keyed term.
|
35
|
+
#
|
36
|
+
def keyed_attributes
|
37
|
+
attributes.each_with_object({}) do |(name, options), keyed_attributes|
|
38
|
+
keyed_attributes[name] = options.block if options.keyed
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [Hash<Symbol, Proc>] The attributes which can be queried using an unkeyed term.
|
43
|
+
#
|
44
|
+
def unkeyed_attributes
|
45
|
+
attributes.each_with_object({}) do |(name, options), unkeyed_attributes|
|
46
|
+
unkeyed_attributes[name] = options.block if options.unkeyed
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [Array<String>] The collection of all possible attributes which can be used as a keyed term.
|
51
|
+
#
|
52
|
+
def keys
|
53
|
+
keys = relations.keys + attributes.select { |_, a| a.keyed }.keys
|
54
|
+
keys.map(&:to_s).uniq
|
55
|
+
end
|
56
|
+
|
57
|
+
# Add a relation to search.
|
58
|
+
#
|
59
|
+
# @param name [Symbol] The name of the relationship attribute.
|
60
|
+
# @param attribute_names [Splat] The name of the attributes within the relationship to search.
|
61
|
+
#
|
62
|
+
def relation(name, *attribute_names)
|
63
|
+
relations[name] = attribute_names
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# Add an attribute to search.
|
68
|
+
#
|
69
|
+
# @param term_name [Symbol] The keyed search term (can be an existing attribute, or a custom value when
|
70
|
+
# passing either the `attribute_name` or a block returning an Arel node).
|
71
|
+
# @param attribute_name [Symbol] The attribute name to search (defaults to the keyword, when left blank and no
|
72
|
+
# block is passed).
|
73
|
+
# @param keyed [Boolean] `true` when the attribute should be searchable using a keyed term,
|
74
|
+
# `false` otherwise.
|
75
|
+
# @param unkeyed [Boolean] `true` when the attribute should be searchable using an unkeyed term,
|
76
|
+
# `false` otherwise.
|
77
|
+
# @param block [Proc] A block which returns the Arel node to query against. When left blank, the
|
78
|
+
# matching attribute from `.arel_table` is queried instead.
|
79
|
+
#
|
80
|
+
def attribute(term_name, attribute_name = nil, keyed: true, unkeyed: true, &block)
|
81
|
+
block ||= -> { record_class.arel_table[attribute_name || term_name] }
|
82
|
+
attributes[term_name] = AttributeOptions.new(keyed, unkeyed, block)
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
end
|
86
|
+
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)
|
@@ -1,11 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Product < ActiveRecord::Base
|
4
|
-
|
4
|
+
belongs_to :category, class_name: 'ProductCategory', inverse_of: :products, optional: true
|
5
5
|
|
6
|
-
|
7
|
-
keyed_attributes: %i[title description rating],
|
8
|
-
unkeyed_attributes: %i[title description]
|
6
|
+
has_many :variations, class_name: 'ProductVariation', inverse_of: :product
|
9
7
|
|
10
8
|
validates :title, presence: true
|
9
|
+
|
10
|
+
searchable do |o|
|
11
|
+
o.relation :variations, :title, :stock_status
|
12
|
+
|
13
|
+
o.attribute :title
|
14
|
+
o.attribute :description
|
15
|
+
o.attribute :rating, unkeyed: false
|
16
|
+
o.attribute :title_length, unkeyed: false do
|
17
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
18
|
+
arel_table[:title]
|
19
|
+
])
|
20
|
+
end
|
21
|
+
|
22
|
+
o.attribute :category, :category_id
|
23
|
+
end
|
11
24
|
end
|
data/spec/internal/db/schema.rb
CHANGED
@@ -1,7 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
ActiveRecord::Schema.define do
|
4
|
+
create_table :product_categories, id: :string, force: true do |t|
|
5
|
+
t.string :name, null: false
|
6
|
+
|
7
|
+
t.timestamps null: false
|
8
|
+
end
|
9
|
+
|
4
10
|
create_table :products, force: true do |t|
|
11
|
+
t.belongs_to :category, type: :string, foreign_key: { to_table: 'product_categories' }
|
12
|
+
|
5
13
|
t.string :title, null: false
|
6
14
|
|
7
15
|
t.text :description
|
@@ -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,148 @@
|
|
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
|
+
describe '#record_class' do
|
15
|
+
subject(:record_class) { search_options.record_class }
|
16
|
+
|
17
|
+
it 'is expected to eq the class passed during initialization' do
|
18
|
+
expect(record_class).to eq(Product)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe '#relations' do
|
23
|
+
subject(:relations) { search_options.relations }
|
24
|
+
|
25
|
+
before do
|
26
|
+
search_options.relation :variations, :title, :stock_status
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'is expected to contain the correct relations' do
|
30
|
+
expect(relations).to eq(variations: %i[title stock_status])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '#keyed_attributes' do
|
35
|
+
subject(:keyed_attributes) { search_options.keyed_attributes }
|
36
|
+
|
37
|
+
before do
|
38
|
+
search_options.attribute :title, keyed: false
|
39
|
+
search_options.attribute :title_length, &title_length_node_builder
|
40
|
+
search_options.attribute :description
|
41
|
+
search_options.attribute :rating, unkeyed: false
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'is expected to contain the correct keyed attributes' do
|
45
|
+
expect(keyed_attributes.keys).to contain_exactly(:title_length, :description, :rating)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'is expected to set a default node builder for attributes declared without a block' do
|
49
|
+
expect(keyed_attributes[:description].call).to eq(Product.arel_table[:description])
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'is expected to set a custom node builder for attributes declared with a block' do
|
53
|
+
expect(keyed_attributes[:title_length]).to eq(title_length_node_builder)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe '#unkeyed_attributes' do
|
58
|
+
subject(:unkeyed_attributes) { search_options.unkeyed_attributes }
|
59
|
+
|
60
|
+
before do
|
61
|
+
search_options.attribute :title, keyed: false
|
62
|
+
search_options.attribute :title_length, &title_length_node_builder
|
63
|
+
search_options.attribute :description
|
64
|
+
search_options.attribute :rating, unkeyed: false
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'is expected to contain the correct unkeyed attributes' do
|
68
|
+
expect(unkeyed_attributes.keys).to contain_exactly(:title, :title_length, :description)
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'is expected to set a default node builder for attributes declared without a block' do
|
72
|
+
expect(unkeyed_attributes[:title].call).to eq(Product.arel_table[:title])
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'is expected to set a custom node builder for attributes declared with a block' do
|
76
|
+
expect(unkeyed_attributes[:title_length]).to eq(title_length_node_builder)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe '#relation' do
|
81
|
+
subject(:relation) { search_options.relation(:variations, :title, :stock_status) }
|
82
|
+
|
83
|
+
it 'is expected to add the relation to #relations' do
|
84
|
+
expect { relation }.to change(search_options, :relations).from({}).to(variations: %i[title stock_status])
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe '#attribute' do
|
89
|
+
subject(:attribute) { search_options.attribute(:description) }
|
90
|
+
|
91
|
+
it { is_expected.to eq(nil) }
|
92
|
+
|
93
|
+
it 'is expected to add the attribute to #attributes' do
|
94
|
+
expect { attribute }.to change(search_options.attributes, :keys).from([]).to(%i[description])
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'is expected to allow keyed searching by default' do
|
98
|
+
attribute
|
99
|
+
expect(search_options.attributes[:description].keyed).to eq(true)
|
100
|
+
end
|
101
|
+
|
102
|
+
it 'is expected to allow unkeyed searching by default' do
|
103
|
+
attribute
|
104
|
+
expect(search_options.attributes[:description].unkeyed).to eq(true)
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'is expected to query the #term_name attribute' do
|
108
|
+
attribute
|
109
|
+
expect(search_options.attributes[:description].block.call).to eq(Product.arel_table[:description])
|
110
|
+
end
|
111
|
+
|
112
|
+
context 'when passing the attribute name to search' do
|
113
|
+
subject(:attribute) { search_options.attribute(:desc, :description) }
|
114
|
+
|
115
|
+
it 'is expected to query the #attribute_name attribute' do
|
116
|
+
attribute
|
117
|
+
expect(search_options.attributes[:desc].block.call).to eq(Product.arel_table[:description])
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
context 'when passing :keyed eq false' do
|
122
|
+
subject(:attribute) { search_options.attribute(:description, keyed: false) }
|
123
|
+
|
124
|
+
it 'is expected to disallow keyed searching' do
|
125
|
+
attribute
|
126
|
+
expect(search_options.attributes[:description].keyed).to eq(false)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
context 'when passing :unkeyed eq false' do
|
131
|
+
subject(:attribute) { search_options.attribute(:description, unkeyed: false) }
|
132
|
+
|
133
|
+
it 'is expected to disallow unkeyed searching' do
|
134
|
+
attribute
|
135
|
+
expect(search_options.attributes[:description].unkeyed).to eq(false)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
context 'when passing a block' do
|
140
|
+
subject(:attribute) { search_options.attribute(:description, &title_length_node_builder) }
|
141
|
+
|
142
|
+
it 'is expected to query the result of the passed block' do
|
143
|
+
attribute
|
144
|
+
expect(search_options.attributes[:description].block).to eq(title_length_node_builder)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -1,17 +1,27 @@
|
|
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.attribute :title
|
11
|
+
o.attribute :description
|
12
|
+
o.attribute :rating, unkeyed: false
|
13
|
+
o.attribute :title_length, unkeyed: false do
|
14
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
15
|
+
Product.arel_table[:title]
|
16
|
+
])
|
17
|
+
end
|
18
|
+
|
19
|
+
o.attribute :category, :category_id
|
20
|
+
end
|
11
21
|
end
|
12
22
|
|
13
|
-
describe '#
|
14
|
-
subject(:
|
23
|
+
describe '#perform' do
|
24
|
+
subject(:perform) { search.perform(query) }
|
15
25
|
|
16
26
|
context 'when passed a blank query' do
|
17
27
|
let(:query) { '' }
|
@@ -25,7 +35,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
25
35
|
end
|
26
36
|
|
27
37
|
it 'is expected to contain all records' do
|
28
|
-
expect(
|
38
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
29
39
|
end
|
30
40
|
end
|
31
41
|
|
@@ -43,7 +53,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
43
53
|
end
|
44
54
|
|
45
55
|
it 'is expected to contain the matching records' do
|
46
|
-
expect(
|
56
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
47
57
|
end
|
48
58
|
end
|
49
59
|
|
@@ -61,7 +71,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
61
71
|
end
|
62
72
|
|
63
73
|
it 'is expected to contain the matching records' do
|
64
|
-
expect(
|
74
|
+
expect(perform).to contain_exactly(product_b)
|
65
75
|
end
|
66
76
|
end
|
67
77
|
|
@@ -79,7 +89,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
79
89
|
end
|
80
90
|
|
81
91
|
it 'is expected to contain the matching records' do
|
82
|
-
expect(
|
92
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
83
93
|
end
|
84
94
|
end
|
85
95
|
|
@@ -97,7 +107,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
97
107
|
end
|
98
108
|
|
99
109
|
it 'is expected to contain the matching records' do
|
100
|
-
expect(
|
110
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
101
111
|
end
|
102
112
|
end
|
103
113
|
|
@@ -115,7 +125,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
115
125
|
end
|
116
126
|
|
117
127
|
it 'is expected to contain the matching records' do
|
118
|
-
expect(
|
128
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
119
129
|
end
|
120
130
|
end
|
121
131
|
|
@@ -133,7 +143,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
133
143
|
end
|
134
144
|
|
135
145
|
it 'is expected to contain the matching records' do
|
136
|
-
expect(
|
146
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
137
147
|
end
|
138
148
|
end
|
139
149
|
|
@@ -151,7 +161,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
151
161
|
end
|
152
162
|
|
153
163
|
it 'is expected to contain the matching records' do
|
154
|
-
expect(
|
164
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
155
165
|
end
|
156
166
|
end
|
157
167
|
|
@@ -169,7 +179,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
169
179
|
end
|
170
180
|
|
171
181
|
it 'is expected to contain the matching records' do
|
172
|
-
expect(
|
182
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
173
183
|
end
|
174
184
|
end
|
175
185
|
|
@@ -187,7 +197,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
187
197
|
end
|
188
198
|
|
189
199
|
it 'is expected to contain the matching records' do
|
190
|
-
expect(
|
200
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
191
201
|
end
|
192
202
|
end
|
193
203
|
|
@@ -205,7 +215,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
205
215
|
end
|
206
216
|
|
207
217
|
it 'is expected to contain the matching records' do
|
208
|
-
expect(
|
218
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
209
219
|
end
|
210
220
|
end
|
211
221
|
|
@@ -223,7 +233,23 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
223
233
|
end
|
224
234
|
|
225
235
|
it 'is expected to contain the matching records' do
|
226
|
-
expect(
|
236
|
+
expect(perform).to contain_exactly(product_b)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
context 'when passed a virtual keyed attribute query' do
|
241
|
+
let(:query) { 'title_length==5' }
|
242
|
+
|
243
|
+
let(:product_a) { Product.create!(title: 'Plain Shirt') }
|
244
|
+
let(:product_b) { Product.create!(title: 'Socks') }
|
245
|
+
|
246
|
+
before do
|
247
|
+
product_a
|
248
|
+
product_b
|
249
|
+
end
|
250
|
+
|
251
|
+
it 'is expected to contain the matching records' do
|
252
|
+
expect(perform).to contain_exactly(product_b)
|
227
253
|
end
|
228
254
|
end
|
229
255
|
|
@@ -247,7 +273,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
247
273
|
end
|
248
274
|
|
249
275
|
it 'is expected to contain the matching records' do
|
250
|
-
expect(
|
276
|
+
expect(perform).to contain_exactly(product_b)
|
251
277
|
end
|
252
278
|
end
|
253
279
|
|
@@ -275,7 +301,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
275
301
|
end
|
276
302
|
|
277
303
|
it 'is expected to contain the matching records' do
|
278
|
-
expect(
|
304
|
+
expect(perform).to contain_exactly(product_c)
|
279
305
|
end
|
280
306
|
end
|
281
307
|
|
@@ -307,7 +333,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
307
333
|
end
|
308
334
|
|
309
335
|
it 'is expected to contain the matching records' do
|
310
|
-
expect(
|
336
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
311
337
|
end
|
312
338
|
end
|
313
339
|
|
@@ -339,7 +365,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
339
365
|
end
|
340
366
|
|
341
367
|
it 'is expected to contain the matching records' do
|
342
|
-
expect(
|
368
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
343
369
|
end
|
344
370
|
end
|
345
371
|
|
@@ -371,7 +397,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
371
397
|
end
|
372
398
|
|
373
399
|
it 'is expected to contain the matching records' do
|
374
|
-
expect(
|
400
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
375
401
|
end
|
376
402
|
end
|
377
403
|
|
@@ -403,7 +429,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
403
429
|
end
|
404
430
|
|
405
431
|
it 'is expected to contain the matching records' do
|
406
|
-
expect(
|
432
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
407
433
|
end
|
408
434
|
end
|
409
435
|
|
@@ -435,7 +461,28 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
435
461
|
end
|
436
462
|
|
437
463
|
it 'is expected to contain the matching records' do
|
438
|
-
expect(
|
464
|
+
expect(perform).to contain_exactly(product_a)
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
context 'when querying a custom attribute whose name matches a reflection' do
|
469
|
+
let(:query) { 'category==shirts' }
|
470
|
+
|
471
|
+
let(:shirts_category) { ProductCategory.create!(id: 'shirts', name: 'The Shirt Collection') }
|
472
|
+
let(:socks_category) { ProductCategory.create!(id: 'socks', name: 'The Sock Collection') }
|
473
|
+
|
474
|
+
let(:product_a) { Product.create!(title: 'Plain Shirt', category: shirts_category) }
|
475
|
+
let(:product_b) { Product.create!(title: 'Funky Shirt', category: shirts_category) }
|
476
|
+
let(:product_c) { Product.create!(title: 'Socks - Pack of 4', category: socks_category) }
|
477
|
+
|
478
|
+
before do
|
479
|
+
product_a
|
480
|
+
product_b
|
481
|
+
product_c
|
482
|
+
end
|
483
|
+
|
484
|
+
it 'is expected to contain the matching records' do
|
485
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
439
486
|
end
|
440
487
|
end
|
441
488
|
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,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pursuit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nialto Services
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-11-
|
11
|
+
date: 2021-11-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -199,21 +199,24 @@ 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
|
210
|
+
- spec/internal/app/models/product_category.rb
|
209
211
|
- spec/internal/app/models/product_variation.rb
|
210
212
|
- spec/internal/config/database.yml
|
211
213
|
- spec/internal/db/schema.rb
|
212
214
|
- spec/internal/log/.keep
|
213
|
-
- spec/pursuit/active_record_dsl_spec.rb
|
214
|
-
- spec/pursuit/active_record_search_spec.rb
|
215
215
|
- spec/pursuit/constants_spec.rb
|
216
|
-
- spec/pursuit/
|
216
|
+
- spec/pursuit/dsl_spec.rb
|
217
|
+
- spec/pursuit/search_options_spec.rb
|
218
|
+
- spec/pursuit/search_spec.rb
|
219
|
+
- spec/pursuit/search_term_parser_spec.rb
|
217
220
|
- spec/spec_helper.rb
|
218
221
|
- travis/gemfiles/5.2.gemfile
|
219
222
|
- travis/gemfiles/6.0.gemfile
|
@@ -244,12 +247,14 @@ specification_version: 4
|
|
244
247
|
summary: Advanced key-based searching for ActiveRecord objects.
|
245
248
|
test_files:
|
246
249
|
- spec/internal/app/models/product.rb
|
250
|
+
- spec/internal/app/models/product_category.rb
|
247
251
|
- spec/internal/app/models/product_variation.rb
|
248
252
|
- spec/internal/config/database.yml
|
249
253
|
- spec/internal/db/schema.rb
|
250
254
|
- spec/internal/log/.keep
|
251
|
-
- spec/pursuit/active_record_dsl_spec.rb
|
252
|
-
- spec/pursuit/active_record_search_spec.rb
|
253
255
|
- spec/pursuit/constants_spec.rb
|
254
|
-
- spec/pursuit/
|
256
|
+
- spec/pursuit/dsl_spec.rb
|
257
|
+
- spec/pursuit/search_options_spec.rb
|
258
|
+
- spec/pursuit/search_spec.rb
|
259
|
+
- spec/pursuit/search_term_parser_spec.rb
|
255
260
|
- 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
|