pursuit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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