pursuit 0.1.0 → 0.3.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/Gemfile.lock +3 -3
- data/README.md +21 -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 +86 -0
- data/lib/pursuit/{term_parser.rb → search_term_parser.rb} +4 -6
- data/lib/pursuit.rb +3 -2
- data/pursuit.gemspec +2 -2
- data/spec/internal/app/models/product.rb +13 -4
- 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} +53 -29
- data/spec/pursuit/search_term_parser_spec.rb +32 -0
- metadata +18 -15
- 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: c5d86e6be4355b96bdaf0d8c02ea680e5cf0f6c3c9f5fdcd3d8e9a20785aad8b
|
4
|
+
data.tar.gz: 310009d659879f97ae8bc9947594533fdb0c11f20702b2bf10e1003c072b4425
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 49991dd50ed278c76d9dbc7f0dd8f48a7f41fa7905f284964a27df9bc90a55ad6833a5911b86ed159d4d4c88fc2aac736ac5feb2c1b8469fb9adbf02abca3f49
|
7
|
+
data.tar.gz: 6663dc11f0f6e6adc08f87834d303ed59e0269415132d7c0fdb54d96ee7ca926be69097dd26dd58d0bc836b9a9bf7f8ccfc35fd3e8f9c6976340e52b0719c5b1
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -20,9 +20,27 @@ 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 :rating, unkeyed: false
|
30
|
+
|
31
|
+
# You can shorten the search keyword by passing the desired search term first, and then the real attribute name
|
32
|
+
# as the second argument.
|
33
|
+
# => "desc*=foo"
|
34
|
+
o.attribute :desc, :description
|
35
|
+
|
36
|
+
# It's also possible to query entirely custom Arel nodes by passing a block which returns the Arel node to query.
|
37
|
+
# You could use this to query a person's full name by concatenating their first and last name columns, for example.
|
38
|
+
o.attribute :title_length, unkeyed: false do
|
39
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
40
|
+
arel_table[:title]
|
41
|
+
])
|
42
|
+
end
|
43
|
+
end
|
26
44
|
end
|
27
45
|
```
|
28
46
|
|
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,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)
|
data/pursuit.gemspec
CHANGED
@@ -21,8 +21,8 @@ Gem::Specification.new do |spec|
|
|
21
21
|
|
22
22
|
spec.metadata['yard.run'] = 'yri'
|
23
23
|
|
24
|
-
spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '< 6.
|
25
|
-
spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '< 6.
|
24
|
+
spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '< 6.2.0'
|
25
|
+
spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '< 6.2.0'
|
26
26
|
|
27
27
|
spec.add_development_dependency 'bundler', '~> 2.0'
|
28
28
|
spec.add_development_dependency 'combustion', '~> 1.1'
|
@@ -3,9 +3,18 @@
|
|
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.attribute :title
|
12
|
+
o.attribute :description
|
13
|
+
o.attribute :rating, unkeyed: false
|
14
|
+
o.attribute :title_length, unkeyed: false do
|
15
|
+
Arel::Nodes::NamedFunction.new('LENGTH', [
|
16
|
+
arel_table[:title]
|
17
|
+
])
|
18
|
+
end
|
19
|
+
end
|
11
20
|
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,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,25 @@
|
|
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
|
+
end
|
11
19
|
end
|
12
20
|
|
13
|
-
describe '#
|
14
|
-
subject(:
|
21
|
+
describe '#perform' do
|
22
|
+
subject(:perform) { search.perform(query) }
|
15
23
|
|
16
24
|
context 'when passed a blank query' do
|
17
25
|
let(:query) { '' }
|
@@ -25,7 +33,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
25
33
|
end
|
26
34
|
|
27
35
|
it 'is expected to contain all records' do
|
28
|
-
expect(
|
36
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
29
37
|
end
|
30
38
|
end
|
31
39
|
|
@@ -43,7 +51,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
43
51
|
end
|
44
52
|
|
45
53
|
it 'is expected to contain the matching records' do
|
46
|
-
expect(
|
54
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
47
55
|
end
|
48
56
|
end
|
49
57
|
|
@@ -61,7 +69,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
61
69
|
end
|
62
70
|
|
63
71
|
it 'is expected to contain the matching records' do
|
64
|
-
expect(
|
72
|
+
expect(perform).to contain_exactly(product_b)
|
65
73
|
end
|
66
74
|
end
|
67
75
|
|
@@ -79,7 +87,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
79
87
|
end
|
80
88
|
|
81
89
|
it 'is expected to contain the matching records' do
|
82
|
-
expect(
|
90
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
83
91
|
end
|
84
92
|
end
|
85
93
|
|
@@ -97,7 +105,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
97
105
|
end
|
98
106
|
|
99
107
|
it 'is expected to contain the matching records' do
|
100
|
-
expect(
|
108
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
101
109
|
end
|
102
110
|
end
|
103
111
|
|
@@ -115,7 +123,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
115
123
|
end
|
116
124
|
|
117
125
|
it 'is expected to contain the matching records' do
|
118
|
-
expect(
|
126
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
119
127
|
end
|
120
128
|
end
|
121
129
|
|
@@ -133,7 +141,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
133
141
|
end
|
134
142
|
|
135
143
|
it 'is expected to contain the matching records' do
|
136
|
-
expect(
|
144
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
137
145
|
end
|
138
146
|
end
|
139
147
|
|
@@ -151,7 +159,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
151
159
|
end
|
152
160
|
|
153
161
|
it 'is expected to contain the matching records' do
|
154
|
-
expect(
|
162
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
155
163
|
end
|
156
164
|
end
|
157
165
|
|
@@ -169,7 +177,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
169
177
|
end
|
170
178
|
|
171
179
|
it 'is expected to contain the matching records' do
|
172
|
-
expect(
|
180
|
+
expect(perform).to contain_exactly(product_b, product_c)
|
173
181
|
end
|
174
182
|
end
|
175
183
|
|
@@ -187,7 +195,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
187
195
|
end
|
188
196
|
|
189
197
|
it 'is expected to contain the matching records' do
|
190
|
-
expect(
|
198
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
191
199
|
end
|
192
200
|
end
|
193
201
|
|
@@ -205,7 +213,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
205
213
|
end
|
206
214
|
|
207
215
|
it 'is expected to contain the matching records' do
|
208
|
-
expect(
|
216
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
209
217
|
end
|
210
218
|
end
|
211
219
|
|
@@ -223,7 +231,23 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
223
231
|
end
|
224
232
|
|
225
233
|
it 'is expected to contain the matching records' do
|
226
|
-
expect(
|
234
|
+
expect(perform).to contain_exactly(product_b)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
context 'when passed a virtual keyed attribute query' do
|
239
|
+
let(:query) { 'title_length==5' }
|
240
|
+
|
241
|
+
let(:product_a) { Product.create!(title: 'Plain Shirt') }
|
242
|
+
let(:product_b) { Product.create!(title: 'Socks') }
|
243
|
+
|
244
|
+
before do
|
245
|
+
product_a
|
246
|
+
product_b
|
247
|
+
end
|
248
|
+
|
249
|
+
it 'is expected to contain the matching records' do
|
250
|
+
expect(perform).to contain_exactly(product_b)
|
227
251
|
end
|
228
252
|
end
|
229
253
|
|
@@ -247,7 +271,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
247
271
|
end
|
248
272
|
|
249
273
|
it 'is expected to contain the matching records' do
|
250
|
-
expect(
|
274
|
+
expect(perform).to contain_exactly(product_b)
|
251
275
|
end
|
252
276
|
end
|
253
277
|
|
@@ -275,7 +299,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
275
299
|
end
|
276
300
|
|
277
301
|
it 'is expected to contain the matching records' do
|
278
|
-
expect(
|
302
|
+
expect(perform).to contain_exactly(product_c)
|
279
303
|
end
|
280
304
|
end
|
281
305
|
|
@@ -307,7 +331,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
307
331
|
end
|
308
332
|
|
309
333
|
it 'is expected to contain the matching records' do
|
310
|
-
expect(
|
334
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
311
335
|
end
|
312
336
|
end
|
313
337
|
|
@@ -339,7 +363,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
339
363
|
end
|
340
364
|
|
341
365
|
it 'is expected to contain the matching records' do
|
342
|
-
expect(
|
366
|
+
expect(perform).to contain_exactly(product_a, product_b)
|
343
367
|
end
|
344
368
|
end
|
345
369
|
|
@@ -371,7 +395,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
371
395
|
end
|
372
396
|
|
373
397
|
it 'is expected to contain the matching records' do
|
374
|
-
expect(
|
398
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
375
399
|
end
|
376
400
|
end
|
377
401
|
|
@@ -403,7 +427,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
403
427
|
end
|
404
428
|
|
405
429
|
it 'is expected to contain the matching records' do
|
406
|
-
expect(
|
430
|
+
expect(perform).to contain_exactly(product_a, product_c)
|
407
431
|
end
|
408
432
|
end
|
409
433
|
|
@@ -435,7 +459,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
|
|
435
459
|
end
|
436
460
|
|
437
461
|
it 'is expected to contain the matching records' do
|
438
|
-
expect(
|
462
|
+
expect(perform).to contain_exactly(product_a)
|
439
463
|
end
|
440
464
|
end
|
441
465
|
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.1
|
4
|
+
version: 0.3.1
|
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
|
@@ -19,7 +19,7 @@ dependencies:
|
|
19
19
|
version: 5.2.0
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: 6.
|
22
|
+
version: 6.2.0
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -29,7 +29,7 @@ dependencies:
|
|
29
29
|
version: 5.2.0
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: 6.
|
32
|
+
version: 6.2.0
|
33
33
|
- !ruby/object:Gem::Dependency
|
34
34
|
name: activesupport
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -39,7 +39,7 @@ dependencies:
|
|
39
39
|
version: 5.2.0
|
40
40
|
- - "<"
|
41
41
|
- !ruby/object:Gem::Version
|
42
|
-
version: 6.
|
42
|
+
version: 6.2.0
|
43
43
|
type: :runtime
|
44
44
|
prerelease: false
|
45
45
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -49,7 +49,7 @@ dependencies:
|
|
49
49
|
version: 5.2.0
|
50
50
|
- - "<"
|
51
51
|
- !ruby/object:Gem::Version
|
52
|
-
version: 6.
|
52
|
+
version: 6.2.0
|
53
53
|
- !ruby/object:Gem::Dependency
|
54
54
|
name: bundler
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
@@ -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
|