pursuit 0.1.0 → 0.3.1

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: 4e63f3c017580251d11f1db0994e4e771f9689e63ed2ae8f4c1c98e14ba27e0b
4
- data.tar.gz: 7956f93f4eea40de80878022796d823b57cbe95da8472a1784ab864fb6b17165
3
+ metadata.gz: c5d86e6be4355b96bdaf0d8c02ea680e5cf0f6c3c9f5fdcd3d8e9a20785aad8b
4
+ data.tar.gz: 310009d659879f97ae8bc9947594533fdb0c11f20702b2bf10e1003c072b4425
5
5
  SHA512:
6
- metadata.gz: 7cfd6c4114d8e02181a8e74a8865f862cfbc904b56a64819b6993a788bf83f847b0859037baf7f43fa3425acb5b33e937875dfffbb0e57abe7dcb094c78e8dde
7
- data.tar.gz: ca46b2fd3b10167df8b74419bd1e41ee200a943b3d73ce5842b52429f89b69fd111ab884b6941d5a84a402ecebab5831941838b10eee0454cb8df8d27c4caca0
6
+ metadata.gz: 49991dd50ed278c76d9dbc7f0dd8f48a7f41fa7905f284964a27df9bc90a55ad6833a5911b86ed159d4d4c88fc2aac736ac5feb2c1b8469fb9adbf02abca3f49
7
+ data.tar.gz: 6663dc11f0f6e6adc08f87834d303ed59e0269415132d7c0fdb54d96ee7ca926be69097dd26dd58d0bc836b9a9bf7f8ccfc35fd3e8f9c6976340e52b0719c5b1
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pursuit (0.1.0)
5
- activerecord (>= 5.2.0, < 6.1.0)
6
- activesupport (>= 5.2.0, < 6.1.0)
4
+ pursuit (0.3.0)
5
+ activerecord (>= 5.2.0, < 6.2.0)
6
+ activesupport (>= 5.2.0, < 6.2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
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
- 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
+ # 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
 
@@ -3,5 +3,5 @@
3
3
  module Pursuit
4
4
  # @return [String] The gem's semantic version number.
5
5
  #
6
- VERSION = '0.1.0'
6
+ VERSION = '0.3.1'
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,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 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)
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.1.0'
25
- spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '< 6.1.0'
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::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.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 '#search' do
14
- subject(:search) { active_record_search.search(query) }
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_b, product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_b)
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(search).to contain_exactly(product_c)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_b)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a, product_c)
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(search).to contain_exactly(product_a)
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.0
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-18 00:00:00.000000000 Z
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.1.0
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.1.0
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.1.0
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.1.0
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/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