pursuit 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7d982a8b67d1a828b2e19e01e3477364bb607fa1fe51ef9d4ac1542a335e319
4
- data.tar.gz: 6a552d6af9ca41bbdc6b4df6cfcfd3cdd3f06c6a4163e8879289cfcef6679fc5
3
+ metadata.gz: 2b49ae3ec2734b8b67fef2fb0686b85af3a694745db4654de863942d4a598d53
4
+ data.tar.gz: 8d8a6e71f23b31d0ebf33a1c2554a9875c0c189786954574c3d5f58ced5fe304
5
5
  SHA512:
6
- metadata.gz: 9b56dd56e67e489faee6f2ba58acd4d68ae75e989cce1cad2a8e578ad830e93cf346168282400d57fc0936029fc93916115bc46dadece9ca1ee7e9c36981981c
7
- data.tar.gz: cbd230e5840c4ed85041dc7463fbf8867bed55d1c114a78b6ca3d65c429350bc1bd3f52044fe7658bad26acc1f93f145f6a0c228ff82bb3d4d64f5d742871294
6
+ metadata.gz: faebb7031e643b0fe4011cee2fda71a916dd12aa2819845a26bf79ef6ca0f0411bcd3e8b5ea1f092624909933639d97d0e59cc2b80ba38ea10f2dcbf0254c154
7
+ data.tar.gz: 0c06093eb83369d20f5e64a843483e98a019a5facb594f75b845949cfdcc0b7027da78ccb8be92d6a739cc76720e1d28165019aaaf7220718deb550c2816440a
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pursuit (0.1.1)
4
+ pursuit (0.2.0)
5
5
  activerecord (>= 5.2.0, < 6.2.0)
6
6
  activesupport (>= 5.2.0, < 6.2.0)
7
7
 
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
- has_search relationships: { variations: %i[title] },
24
- keyed_attributes: %i[title description rating],
25
- unkeyed_attributes: %i[title description]
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
 
@@ -3,5 +3,5 @@
3
3
  module Pursuit
4
4
  # @return [String] The gem's semantic version number.
5
5
  #
6
- VERSION = '0.1.1'
6
+ VERSION = '0.2.0'
7
7
  end
@@ -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
@@ -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/active_record_dsl'
7
+ require 'pursuit/dsl'
8
8
 
9
- ActiveRecord::Base.include(Pursuit::ActiveRecordDSL)
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 TermParser
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::TermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
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 `TermParser` by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
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<Symbol>] The keys which are permitted for use as keyed terms.
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/term_parser'
5
- require 'pursuit/active_record_search'
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::ActiveRecordSearch do
4
- subject(:active_record_search) do
5
- described_class.new(
6
- Product,
7
- relationships: { variations: %i[title stock_status] },
8
- keyed_attributes: %i[title description rating],
9
- unkeyed_attributes: %i[title description]
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 '#search' do
14
- subject(:search) { active_record_search.search(query) }
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a)
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.1.1
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/term_parser.rb
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/term_parser_spec.rb
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/term_parser_spec.rb
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,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- RSpec.describe Pursuit::ActiveRecordDSL do
4
- subject(:product) { Product.new }
5
-
6
- it { is_expected.to respond_to(:search) }
7
- 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